diff options
Diffstat (limited to 'addons/mail/static')
362 files changed, 71377 insertions, 0 deletions
diff --git a/addons/mail/static/description/icon.png b/addons/mail/static/description/icon.png Binary files differnew file mode 100644 index 00000000..0f034a48 --- /dev/null +++ b/addons/mail/static/description/icon.png diff --git a/addons/mail/static/description/icon.svg b/addons/mail/static/description/icon.svg new file mode 100644 index 00000000..a71744fa --- /dev/null +++ b/addons/mail/static/description/icon.svg @@ -0,0 +1 @@ +<svg width="70" height="70" viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>mail/static/description/icon</title><defs><path d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z" id="a"/><linearGradient x1="100%" y1="0%" x2="0%" y2="100%" id="c"><stop stop-color="#CD7690" offset="0%"/><stop stop-color="#CA5377" offset="100%"/></linearGradient><path d="M57.706 35.71c0 9.276-10.012 16.777-22.351 16.777-3.749 0-7.287-.694-10.392-1.92-3.127 2.517-6.969 4.057-11.051 4.493a.835.835 0 0 1-.893-.621c-.1-.404.21-.654.513-.952 1.497-1.484 3.313-2.646 4.027-7.63-2.855-2.815-4.555-6.331-4.555-10.146 0-9.267 10.011-16.776 22.35-16.776 12.34 0 22.352 7.509 22.352 16.776zm-33.647-6.534a1 1 0 0 0-1 1v1.353a1 1 0 0 0 1 1h22.588a1 1 0 0 0 1-1v-1.353a1 1 0 0 0-1-1H24.06zm0 8.056a1 1 0 0 0-1 1v1.353a1 1 0 0 0 1 1h22.588a1 1 0 0 0 1-1v-1.353a1 1 0 0 0-1-1H24.06z" id="d"/><path d="M57.706 33.71c0 9.276-10.012 16.777-22.351 16.777-3.749 0-7.287-.694-10.392-1.92-3.127 2.517-6.969 4.057-11.051 4.493a.835.835 0 0 1-.893-.621c-.1-.404.21-.654.513-.952 1.497-1.484 3.313-2.646 4.027-7.63-2.855-2.815-4.555-6.331-4.555-10.146 0-9.267 10.011-16.776 22.35-16.776 12.34 0 22.352 7.509 22.352 16.776zm-33.647-6.534a1 1 0 0 0-1 1v1.353a1 1 0 0 0 1 1h22.588a1 1 0 0 0 1-1v-1.353a1 1 0 0 0-1-1H24.06zm0 8.056a1 1 0 0 0-1 1v1.353a1 1 0 0 0 1 1h22.588a1 1 0 0 0 1-1v-1.353a1 1 0 0 0-1-1H24.06z" id="e"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z" fill="#FFF" fill-opacity=".383"/><path d="M36.847 68.65L4 69c-2 0-4-.146-4-4.098V46.5L17.288 24 54 24.95v17.92L36.847 68.65z" fill="#393939" opacity=".324"/><path d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z" fill="#000" fill-opacity=".383"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>
\ No newline at end of file diff --git a/addons/mail/static/img/Capture.PNG b/addons/mail/static/img/Capture.PNG Binary files differnew file mode 100644 index 00000000..86e2e70a --- /dev/null +++ b/addons/mail/static/img/Capture.PNG diff --git a/addons/mail/static/img/msg_discus4_attach2-datas.jpg b/addons/mail/static/img/msg_discus4_attach2-datas.jpg Binary files differnew file mode 100644 index 00000000..385a116f --- /dev/null +++ b/addons/mail/static/img/msg_discus4_attach2-datas.jpg diff --git a/addons/mail/static/img/red_envelope.png b/addons/mail/static/img/red_envelope.png Binary files differnew file mode 100644 index 00000000..8a6152ae --- /dev/null +++ b/addons/mail/static/img/red_envelope.png diff --git a/addons/mail/static/scripts/odoo-mailgate.py b/addons/mail/static/scripts/odoo-mailgate.py new file mode 100755 index 00000000..e4ccc3c9 --- /dev/null +++ b/addons/mail/static/scripts/odoo-mailgate.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python2 +# Part of Odoo. See LICENSE file for full copyright and licensing details. +# +# odoo-mailgate +# +# This program will read an email from stdin and forward it to odoo. Configure +# a pipe alias in your mail server to use it, postfix uses a syntax that looks +# like: +# +# email@address: "|/home/odoo/src/odoo-mail.py" +# +# while exim uses a syntax that looks like: +# +# *: |/home/odoo/src/odoo-mail.py +# +# Note python2 was chosen on purpose for backward compatibility with old mail +# servers. +# +import optparse +import sys +import traceback +import xmlrpclib + +def main(): + op = optparse.OptionParser(usage='usage: %prog [options]', version='%prog v1.2') + op.add_option("-d", "--database", dest="database", help="Odoo database name (default: %default)", default='odoo') + op.add_option("-u", "--userid", dest="userid", help="Odoo user id to connect with (default: %default)", default=1, type=int) + op.add_option("-p", "--password", dest="password", help="Odoo user password (default: %default)", default='admin') + op.add_option("--host", dest="host", help="Odoo host (default: %default)", default='localhost') + op.add_option("--port", dest="port", help="Odoo port (default: %default)", default=8069, type=int) + (o, args) = op.parse_args() + + try: + msg = sys.stdin.read() + models = xmlrpclib.ServerProxy('http://%s:%s/xmlrpc/2/object' % (o.host, o.port), allow_none=True) + models.execute_kw(o.database, o.userid, o.password, 'mail.thread', 'message_process', [False, xmlrpclib.Binary(msg)], {}) + except xmlrpclib.Fault as e: + # reformat xmlrpc faults to print a readable traceback + err = "xmlrpclib.Fault: %s\n%s" % (e.faultCode, e.faultString) + sys.exit(err) + except Exception as e: + traceback.print_exc(None, sys.stderr) + sys.exit(2) + +if __name__ == '__main__': + main() diff --git a/addons/mail/static/src/audio/ting.mp3 b/addons/mail/static/src/audio/ting.mp3 Binary files differnew file mode 100644 index 00000000..fd29bf59 --- /dev/null +++ b/addons/mail/static/src/audio/ting.mp3 diff --git a/addons/mail/static/src/audio/ting.ogg b/addons/mail/static/src/audio/ting.ogg Binary files differnew file mode 100644 index 00000000..cc8c0f01 --- /dev/null +++ b/addons/mail/static/src/audio/ting.ogg diff --git a/addons/mail/static/src/bugfix/bugfix.js b/addons/mail/static/src/bugfix/bugfix.js new file mode 100644 index 00000000..20800dc7 --- /dev/null +++ b/addons/mail/static/src/bugfix/bugfix.js @@ -0,0 +1,191 @@ +/** + * This file allows introducing new JS modules without contaminating other files. + * This is useful when bug fixing requires adding such JS modules in stable + * versions of Odoo. Any module that is defined in this file should be isolated + * in its own file in master. + */ +odoo.define('mail/static/src/bugfix/bugfix.js', function (require) { +'use strict'; + +}); + +// Should be moved to its own file in master. +odoo.define('mail/static/src/component_hooks/use_rendered_values/use_rendered_values.js', function (require) { +'use strict'; + +const { Component } = owl; +const { onMounted, onPatched } = owl.hooks; + +/** + * This hooks provides support for accessing the values returned by the given + * selector at the time of the last render. The values will be updated after + * every mount/patch. + * + * @param {function} selector function that will be executed at the time of the + * render and of which the result will be stored for future reference. + * @returns {function} function to call to retrieve the last rendered values. + */ +function useRenderedValues(selector) { + const component = Component.current; + let renderedValues; + let patchedValues; + + const __render = component.__render.bind(component); + component.__render = function () { + renderedValues = selector(); + return __render(...arguments); + }; + onMounted(onUpdate); + onPatched(onUpdate); + function onUpdate() { + patchedValues = renderedValues; + } + return () => patchedValues; +} + +return useRenderedValues; + +}); + +// Should be moved to its own file in master. +odoo.define('mail/static/src/component_hooks/use_update/use_update.js', function (require) { +'use strict'; + +const { Component } = owl; +const { onMounted, onPatched } = owl.hooks; + +const executionQueue = []; + +function executeNextInQueue() { + if (executionQueue.length === 0) { + return; + } + const { component, func } = executionQueue.shift(); + if (component.__owl__.status !== 5 /* DESTROYED */) { + func(); + } + executeNextInQueue(); +} + +/** + * @param {Object} param0 + * @param {Component} param0.component + * @param {function} param0.func + * @param {integer} param0.priority + */ +async function addFunctionToQueue({ component, func, priority }) { + const index = executionQueue.findIndex(item => item.priority > priority); + const item = { component, func, priority }; + if (index === -1) { + executionQueue.push(item); + } else { + executionQueue.splice(index, 0, item); + } + // Timeout to allow all components to register their function before + // executing any of them, to respect all priorities. + await new Promise(resolve => setTimeout(resolve)); + executeNextInQueue(); +} + +/** + * This hook provides support for executing code after update (render or patch). + * + * @param {Object} param0 + * @param {function} param0.func the function to execute after the update. + * @param {integer} [param0.priority] determines the execution order of the function + * among the update function of other components. Lower priority is executed + * first. If no priority is given, the function is executed immediately. + * This param is deprecated because desynchronized update is causing issue if + * there is a new render planned in the meantime (models data become obsolete + * in the update method). + */ +function useUpdate({ func, priority }) { + const component = Component.current; + onMounted(onUpdate); + onPatched(onUpdate); + function onUpdate() { + if (priority === undefined) { + func(); + return; + } + addFunctionToQueue({ component, func, priority }); + } +} + +return useUpdate; + +}); + +// Should be moved to its own file in master. +odoo.define('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js', function (require) { +'use strict'; + +const { Component } = owl; + +/** + * Compares `a` and `b` up to the given `compareDepth`. + * + * @param {any} a + * @param {any} b + * @param {Object|integer} compareDepth + * @returns {boolean} + */ +function isEqual(a, b, compareDepth) { + const keys = Object.keys(a); + if (Object.keys(b).length !== keys.length) { + return false; + } + for (const key of keys) { + // the depth can be given either as a number (for all keys) or as + // an object (for each key) + let depth; + if (typeof compareDepth === 'number') { + depth = compareDepth; + } else { + depth = compareDepth[key] || 0; + } + if (depth === 0 && a[key] !== b[key]) { + return false; + } + if (depth !== 0) { + let nextDepth; + if (typeof depth === 'number') { + nextDepth = depth - 1; + } else { + nextDepth = depth; + } + if (!isEqual(a[key], b[key], nextDepth)) { + return false; + } + } + } + return true; +} + +/** + * This hook overrides the `shouldUpdate` method to ensure the component is only + * updated if its props actually changed. This is especially useful to use on + * components for which an extra render costs proportionally a lot more than + * comparing props. + * + * @param {Object} [param0={}] + * @param {Object} [param0.compareDepth={}] allows to specify the comparison + * depth to use for each prop. Default is shallow compare (depth = 0). + */ +function useShouldUpdateBasedOnProps({ compareDepth = {} } = {}) { + const component = Component.current; + component.shouldUpdate = nextProps => { + const allNewProps = Object.assign({}, nextProps); + const defaultProps = component.constructor.defaultProps; + for (const key in defaultProps) { + if (allNewProps[key] === undefined) { + allNewProps[key] = defaultProps[key]; + } + } + return !isEqual(component.props, allNewProps, compareDepth); + }; +} + +return useShouldUpdateBasedOnProps; + +}); diff --git a/addons/mail/static/src/bugfix/bugfix.scss b/addons/mail/static/src/bugfix/bugfix.scss new file mode 100644 index 00000000..c4272e52 --- /dev/null +++ b/addons/mail/static/src/bugfix/bugfix.scss @@ -0,0 +1,6 @@ +/** +* This file allows introducing new styles without contaminating other files. +* This is useful when bug fixing requires adding new components for instance in +* stable versions of Odoo. Any style that is defined in this file should be isolated +* in its own file in master. +*/ diff --git a/addons/mail/static/src/bugfix/bugfix.xml b/addons/mail/static/src/bugfix/bugfix.xml new file mode 100644 index 00000000..c17906f7 --- /dev/null +++ b/addons/mail/static/src/bugfix/bugfix.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + +<!-- + This file allows introducing new static templates without contaminating other files. + This is useful when bug fixing requires adding new components for instance in stable + versions of Odoo. Any template that is defined in this file should be isolated + in its own file in master. +--> + +</templates> diff --git a/addons/mail/static/src/bugfix/bugfix_tests.js b/addons/mail/static/src/bugfix/bugfix_tests.js new file mode 100644 index 00000000..a5ecddde --- /dev/null +++ b/addons/mail/static/src/bugfix/bugfix_tests.js @@ -0,0 +1,18 @@ +odoo.define('mail/static/src/bugfix/bugfix_tests.js', function (require) { +'use strict'; + +/** + * This file allows introducing new QUnit test modules without contaminating + * other test files. This is useful when bug fixing requires adding new + * components for instance in stable versions of Odoo. Any test that is defined + * in this file should be isolated in its own file in master. + */ +QUnit.module('mail', {}, function () { +QUnit.module('bugfix', {}, function () { +QUnit.module('bugfix_tests.js', { + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js b/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js new file mode 100644 index 00000000..f96eb265 --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js @@ -0,0 +1,93 @@ +odoo.define('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js', function (require) { +'use strict'; + +const { useState, onMounted, onWillUnmount } = owl.hooks; + +/** + * This hook handle the visibility of the dropzone based on drag & drop events. + * It needs a ref to a dropzone, so you need to specify a t-ref="dropzone" in + * the template of your component. + * + * @returns {Object} + */ +function useDragVisibleDropZone() { + /** + * Determine whether the drop zone should be visible or not. + * Note that this is an observed value, and primitive types such as + * boolean cannot be observed, hence this is an object with boolean + * value accessible from `.value` + */ + const isVisible = useState({ value: false }); + + /** + * Counts how many drag enter/leave happened globally. This is the only + * way to know if a file has been dragged out of the browser window. + */ + let dragCount = 0; + + // COMPONENTS HOOKS + onMounted(() => { + document.addEventListener('dragenter', _onDragenterListener, true); + document.addEventListener('dragleave', _onDragleaveListener, true); + document.addEventListener('drop', _onDropListener, true); + + // Thoses Events prevent the browser to open or download the file if + // it's dropped outside of the dropzone + window.addEventListener('dragover', ev => ev.preventDefault()); + window.addEventListener('drop', ev => ev.preventDefault()); + }); + + onWillUnmount(() => { + document.removeEventListener('dragenter', _onDragenterListener, true); + document.removeEventListener('dragleave', _onDragleaveListener, true); + document.removeEventListener('drop', _onDropListener, true); + + window.removeEventListener('dragover', ev => ev.preventDefault()); + window.removeEventListener('drop', ev => ev.preventDefault()); + }); + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Shows the dropzone when entering the browser window, to let the user know + * where he can drop its file. + * Avoids changing state when entering inner dropzones. + * + * @private + * @param {DragEvent} ev + */ + function _onDragenterListener(ev) { + if (dragCount === 0) { + isVisible.value = true; + } + dragCount++; + } + + /** + * @private + * @param {DragEvent} ev + */ + function _onDragleaveListener(ev) { + dragCount--; + if (dragCount === 0) { + isVisible.value = false; + } + } + + /** + * @private + * @param {DragEvent} ev + */ + function _onDropListener(ev) { + dragCount = 0; + isVisible.value = false; + } + + return isVisible; +} + +return useDragVisibleDropZone; + +}); diff --git a/addons/mail/static/src/component_hooks/use_refs/use_refs.js b/addons/mail/static/src/component_hooks/use_refs/use_refs.js new file mode 100644 index 00000000..6c84f195 --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_refs/use_refs.js @@ -0,0 +1,21 @@ +odoo.define('mail/static/src/component_hooks/use_refs/use_refs.js', function (require) { +'use strict'; + +const { Component } = owl; + +/** + * This hook provides support for dynamic-refs. + * + * @returns {function} returns object whose keys are t-ref values of active refs. + * and values are refs. + */ +function useRefs() { + const component = Component.current; + return function () { + return component.__owl__.refs || {}; + }; +} + +return useRefs; + +}); diff --git a/addons/mail/static/src/component_hooks/use_store/use_store.js b/addons/mail/static/src/component_hooks/use_store/use_store.js new file mode 100644 index 00000000..7ff5ea3a --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_store/use_store.js @@ -0,0 +1,126 @@ +odoo.define('mail/static/src/component_hooks/use_store/use_store.js', function (require) { +'use strict'; + +/** + * Similar to owl.hooks.useStore but to decide if a new render has to be done it + * compares the keys on the result, with an optional level of depth for each + * key, given as options `compareDepth`. + * + * It assumes that the result of the selector is always an object (or array). + * + * @param {function} selector function passed as selector of original `useStore` + * with 1st parameter extended as store state. @see owl.hooks.useStore + * @param {object} [options={}] @see owl.hooks.useStore + * @param {number|object} [options.compareDepth=0] the comparison depth, either + * as number (applies to all keys) or as an object (depth for specific keys) + * @returns {Proxy} @see owl.hooks.useStore + */ +function useStore(selector, options = {}) { + const store = options.store || owl.Component.current.env.store; + const hashFn = store.observer.revNumber.bind(store.observer); + const isEqual = options.isEqual || ((a, b) => a === b); + + /** + * Returns a function comparing whether two values are the same, which is just + * calling `isEqual` on primitive values and objects, but which also works for + * Owl Proxy in a temporal way: the current result of hashFn is compared to the + * previous result of hashFn (from the last time the function was called). + * + * It means that when this function is given Proxy the first call will always + * return false, and consecutive calls will not lead to the same result: + * it returns true the first time after a change happended inside the Proxy, and + * then always returns false until a new change is made. + * + * @returns {function} function taking two arguments, and comparing them as + * explained above. + */ + function proxyComparator() { + /** + * It is important to locally save the old `revNumber` of each resulting + * value because when the "old" and "new" values are the same proxy it is + * impossible to compare them based on their current value (since it was + * updated in "both" due to the fact it is a proxy in the first place). + * + * And if the values are not proxy, `revNumber` will be 0 and the `isEqual` + * will be used to compare them. + */ + let oldRevNumber; + + function compare(a, b) { + let ok = true; + const newRevNumber = hashFn(b); + if (a === b && newRevNumber > 0) { + ok = oldRevNumber === newRevNumber; + } else { + ok = isEqual(a, b); + } + oldRevNumber = newRevNumber; + return ok; + } + + return compare; + } + + /** + * @see proxyComparator, but instead of comparing the given values, it compares + * their respective keys, with `compareDepth` level of depth. + * 0 = compare key, 1 = also compare subkeys, ... + * + * This assumes the given values are objects or arrays. + * + * @param {number|object} compareDepth the comparison depth, either as number + * (applies to all keys) or as an object (depth for specific keys) + * @returns {function} + */ + function proxyComparatorDeep(compareDepth = 0) { + const comparator = proxyComparator(); + const comparators = {}; + + function compare(a, b) { + // If a and b are (the same) proxy, it is already managing the depth + // by itself, and a simple comparator can be used. + if (a === b && hashFn(b) > 0) { + return comparator(a, b); + } + let ok = true; + const newKeys = Object.keys(b); + if (!a || (Object.keys(a).length !== newKeys.length)) { + ok = false; + } + for (const key of newKeys) { + // the depth can be given either as a number (for all keys) or as + // an object (for each key) + let depth; + if (typeof compareDepth === 'number') { + depth = compareDepth; + } else { + depth = compareDepth[key] || 0; + } + if (!(key in comparators)) { + if (depth > 0) { + comparators[key] = proxyComparatorDeep(depth - 1); + } else { + comparators[key] = proxyComparator(); + } + } + // It is important to not break too early, the comparator has to + // be called for every key to remember their current states. + if (!comparators[key](a ? a[key] : a, b[key])) { + ok = false; + } + } + return ok; + } + + return compare; + } + + const extendedSelector = (state, props) => selector(props); + return owl.hooks.useStore(extendedSelector, Object.assign({}, options, { + isEqual: proxyComparatorDeep(options.compareDepth), + })); +} + +return useStore; + +}); diff --git a/addons/mail/static/src/component_hooks/use_store/use_store_tests.js b/addons/mail/static/src/component_hooks/use_store/use_store_tests.js new file mode 100644 index 00000000..f1a4b054 --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_store/use_store_tests.js @@ -0,0 +1,189 @@ +odoo.define('mail/static/src/component_hooks/use_store/use_store_tests.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const { + afterNextRender, + nextAnimationFrame, +} = require('mail/static/src/utils/test_utils.js'); + +const { Component, QWeb, Store } = owl; +const { onPatched, useGetters } = owl.hooks; +const { xml } = owl.tags; + +QUnit.module('mail', {}, function () { +QUnit.module('component_hooks', {}, function () { +QUnit.module('use_store', {}, function () { +QUnit.module('use_store_tests.js', { + beforeEach() { + const qweb = new QWeb(); + this.env = { qweb }; + }, + afterEach() { + this.env = undefined; + this.store = undefined; + }, +}); + + +QUnit.test("compare keys, no depth, primitives", async function (assert) { + assert.expect(8); + this.store = new Store({ + env: this.env, + getters: { + get({ state }, key) { + return state[key]; + }, + }, + state: { + obj: { + subObj1: 'a', + subObj2: 'b', + use1: true, + }, + }, + }); + this.env.store = this.store; + let count = 0; + class MyComponent extends Component { + constructor() { + super(...arguments); + this.storeGetters = useGetters(); + this.storeProps = useStore(props => { + const obj = this.storeGetters.get('obj'); + return { + res: obj.use1 ? obj.subObj1 : obj.subObj2, + }; + }); + onPatched(() => { + count++; + }); + } + } + Object.assign(MyComponent, { + env: this.env, + props: {}, + template: xml`<div t-esc="storeProps.res"/>`, + }); + + const fixture = document.querySelector('#qunit-fixture'); + + const myComponent = new MyComponent(); + await myComponent.mount(fixture); + assert.strictEqual(count, 0, + 'should not detect an update initially'); + assert.strictEqual(fixture.textContent, 'a', + 'should display the content of subObj1'); + + await afterNextRender(() => { + this.store.state.obj.use1 = false; + }); + assert.strictEqual(count, 1, + 'should detect an update because the selector is returning a different value (was subObj1, now is subObj2)'); + assert.strictEqual(fixture.textContent, 'b', + 'should display the content of subObj2'); + + this.store.state.obj.subObj2 = 'b'; + // there must be no render here + await nextAnimationFrame(); + assert.strictEqual(count, 1, + 'should not detect an update because the same primitive value was assigned (subObj2 was already "b")'); + assert.strictEqual(fixture.textContent, 'b', + 'should still display the content of subObj2'); + + await afterNextRender(() => { + this.store.state.obj.subObj2 = 'd'; + }); + assert.strictEqual(count, 2, + 'should detect an update because the selector is returning a different value for subObj2'); + assert.strictEqual(fixture.textContent, 'd', + 'should display the new content of subObj2'); + + myComponent.destroy(); +}); + +QUnit.test("compare keys, depth 1, proxy", async function (assert) { + assert.expect(8); + this.store = new Store({ + env: this.env, + getters: { + get({ state }, key) { + return state[key]; + }, + }, + state: { + obj: { + subObj1: { a: 'a' }, + subObj2: { a: 'b' }, + use1: true, + }, + }, + }); + this.env.store = this.store; + let count = 0; + class MyComponent extends Component { + constructor() { + super(...arguments); + this.storeGetters = useGetters(); + this.storeProps = useStore(props => { + const obj = this.storeGetters.get('obj'); + return { + array: [obj.use1 ? obj.subObj1 : obj.subObj2], + }; + }, { + compareDepth: { + array: 1, + }, + }); + onPatched(() => { + count++; + }); + } + } + Object.assign(MyComponent, { + env: this.env, + props: {}, + template: xml`<div t-esc="storeProps.array[0].a"/>`, + }); + + const fixture = document.querySelector('#qunit-fixture'); + + const myComponent = new MyComponent(); + await myComponent.mount(fixture); + assert.strictEqual(count, 0, + 'should not detect an update initially'); + assert.strictEqual(fixture.textContent, 'a', + 'should display the content of subObj1'); + + await afterNextRender(() => { + this.store.state.obj.use1 = false; + }); + assert.strictEqual(count, 1, + 'should detect an update because the selector is returning a different value (was subObj1, now is subObj2)'); + assert.strictEqual(fixture.textContent, 'b', + 'should display the content of subObj2'); + + this.store.state.obj.subObj1.a = 'c'; + // there must be no render here + await nextAnimationFrame(); + assert.strictEqual(count, 1, + 'should not detect an update because subObj1 was changed but only subObj2 is selected'); + assert.strictEqual(fixture.textContent, 'b', + 'should still display the content of subObj2'); + + await afterNextRender(() => { + this.store.state.obj.subObj2.a = 'd'; + }); + assert.strictEqual(count, 2, + 'should detect an update because the value of subObj2 changed'); + assert.strictEqual(fixture.textContent, 'd', + 'should display the new content of subObj2'); + + myComponent.destroy(); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/activity/activity.js b/addons/mail/static/src/components/activity/activity.js new file mode 100644 index 00000000..1ee7ecf3 --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.js @@ -0,0 +1,199 @@ +odoo.define('mail/static/src/components/activity/activity.js', function (require) { +'use strict'; + +const components = { + ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), + MailTemplate: require('mail/static/src/components/mail_template/mail_template.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { + auto_str_to_date, + getLangDateFormat, + getLangDatetimeFormat, +} = require('web.time'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +class Activity extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + this.state = useState({ + areDetailsVisible: false, + }); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + assigneeNameOrDisplayName: ( + activity && + activity.assignee && + activity.assignee.nameOrDisplayName + ), + }; + }); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {string} + */ + get assignedUserText() { + return _.str.sprintf(this.env._t("for %s"), this.activity.assignee.nameOrDisplayName); + } + + /** + * @returns {string} + */ + get delayLabel() { + const today = moment().startOf('day'); + const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline)); + // true means no rounding + const diff = momentDeadlineDate.diff(today, 'days', true); + if (diff === 0) { + return this.env._t("Today:"); + } else if (diff === -1) { + return this.env._t("Yesterday:"); + } else if (diff < 0) { + return _.str.sprintf(this.env._t("%d days overdue:"), Math.abs(diff)); + } else if (diff === 1) { + return this.env._t("Tomorrow:"); + } else { + return _.str.sprintf(this.env._t("Due in %d days:"), Math.abs(diff)); + } + } + + /** + * @returns {string} + */ + get formattedCreateDatetime() { + const momentCreateDate = moment(auto_str_to_date(this.activity.dateCreate)); + const datetimeFormat = getLangDatetimeFormat(); + return momentCreateDate.format(datetimeFormat); + } + + /** + * @returns {string} + */ + get formattedDeadlineDate() { + const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline)); + const datetimeFormat = getLangDateFormat(); + return momentDeadlineDate.format(datetimeFormat); + } + + /** + * @returns {string} + */ + get MARK_DONE() { + return this.env._t("Mark Done"); + } + + /** + * @returns {string} + */ + get summary() { + return _.str.sprintf(this.env._t("“%s”"), this.activity.summary); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.attachment} ev.detail.attachment + */ + _onAttachmentCreated(ev) { + this.activity.markAsDone({ attachments: [ev.detail.attachment] }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if ( + ev.target.tagName === 'A' && + ev.target.dataset.oeId && + ev.target.dataset.oeModel + ) { + this.env.messaging.openProfile({ + id: Number(ev.target.dataset.oeId), + model: ev.target.dataset.oeModel, + }); + // avoid following dummy href + ev.preventDefault(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickCancel(ev) { + ev.preventDefault(); + await this.activity.deleteServerRecord(); + this.trigger('reload', { keepChanges: true }); + } + + /** + * @private + */ + _onClickDetailsButton() { + this.state.areDetailsVisible = !this.state.areDetailsVisible; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEdit(ev) { + this.activity.edit(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUploadDocument(ev) { + this._fileUploaderRef.comp.openBrowserFileUploader(); + } + +} + +Object.assign(Activity, { + components, + props: { + activityLocalId: String, + }, + template: 'mail.Activity', +}); + +return Activity; + +}); diff --git a/addons/mail/static/src/components/activity/activity.scss b/addons/mail/static/src/components/activity/activity.scss new file mode 100644 index 00000000..64a0ceac --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.scss @@ -0,0 +1,186 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Activity { + display: flex; + flex: 0 0 auto; + padding: map-get($spacers, 2); +} + +.o_Activity_detailsUserAvatar { + margin-inline-end: map-get($spacers, 2); + object-fit: cover; + height: 18px; + width: 18px; +} + +.o_Activity_dueDateText, .o_Activity_summary { + margin-inline-end: map-get($spacers, 2); +} + +.o_Activity_iconContainer { + @include o-position-absolute($top: auto, $left: auto, $bottom: -5px, $right: -5px); + display: flex; + align-items: center; + justify-content: center; + width: 25px; + height: 25px; + border-width: 2px; +} + +.o_Activity_info { + display: flex; + align-items: baseline; +} + +.o_Activity_note p { + margin-bottom: 0; +} + +.o_Activity_sidebar { + display: flex; + flex: 0 0 36px; + margin-right: map-get($spacers, 3); + justify-content: center; +} + +.o_Activity_toolButton { + padding-top: map-get($spacers, 0); +} + +.o_Activity_tools { + display: flex; +} + +.o_Activity_user { + height: 36px; + margin-left: map-get($spacers, 2); + margin-right: map-get($spacers, 2); + position: relative; + width: 36px; +} + +.o_Activity_userAvatar { + height: map-get($sizes, 100); + width: map-get($sizes, 100); +} + +// From python template +.o_mail_note_title { + margin-top: map-get($spacers, 2); +} + +.o_mail_note_title + div p { + margin-bottom: 0; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +$o-mail-activity-default-color: gray('300') !default; +$o-mail-activity-overdue-color: darken(theme-color('danger'), 10%) !default; +$o-mail-activity-planned-color: darken(theme-color('success'), 10%) !default; +$o-mail-activity-today-color: darken(theme-color('warning'), 10%) !default; + + +.o_Activity_deadlineDateText { + &.o-default { + color: $o-mail-activity-default-color; + } + + &.o-overdue { + color: $o-mail-activity-overdue-color; + } + + &.o-planned { + color: $o-mail-activity-planned-color; + } + + &.o-today { + color: $o-mail-activity-today-color; + } +} + +.o_Activity_details { + color: gray('500'); +} + +.o_Activity_detailsCreatorAvatar { + margin-inline-start: map-get($spacers, 2); +} + +.o_Activity_detailsUserAvatar { + border-radius: 50%; +} + +.o_Activity_dueDateText { + font-weight: bolder; + + &.o-default { + color: $o-mail-activity-default-color; + } + + &.o-overdue { + color: $o-mail-activity-overdue-color; + } + + &.o-planned { + color: $o-mail-activity-planned-color; + } + + &.o-today { + color: $o-mail-activity-today-color; + } +} + +/* Needed specifity to counter default bootstrap style */ +a:not([href]):not([tabindex]).o_Activity_detailsButton { + background: transparent; + opacity: 0.5; + color: gray('500'); + + &:hover { + opacity: 1; + color: gray('600'); + } +} + +.o_Activity_detailsCreator { + font-weight: bold; +} + +.o_Activity_iconContainer { + color: white; + border-color: white; + border-radius: 100%; + border-style: solid; +} + +.o_Activity_sidebar { + font-size: smaller; +} + +.o_Activity_summary { + font-weight: bolder; + color: gray('900'); +} + +.o_Activity_toolButton { + opacity: 0.5; + color: gray('500'); + + &:hover { + opacity: 1; + color: gray('600'); + } +} + +.o_Activity_userAvatar { + border-radius: 50%; +} + +.o_Activity_userName { + color: gray('500'); +} diff --git a/addons/mail/static/src/components/activity/activity.xml b/addons/mail/static/src/components/activity/activity.xml new file mode 100644 index 00000000..e5e9832c --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.xml @@ -0,0 +1,153 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Activity" owl="1"> + <div class="o_Activity" t-on-click="_onClick"> + <t t-if="activity"> + <div class="o_Activity_sidebar"> + <div class="o_Activity_user"> + <t t-if="activity.assignee"> + <img class="o_Activity_userAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-alt="activity.assignee.nameOrDisplayName"/> + </t> + <div class="o_Activity_iconContainer" + t-att-class="{ + 'bg-success-full': activity.state === 'planned', + 'bg-warning-full': activity.state === 'today', + 'bg-danger-full': activity.state === 'overdue', + }" + > + <i class="o_Activity_icon fa" t-attf-class="{{ activity.icon }}"/> + </div> + </div> + </div> + <div class="o_Activity_core"> + <div class="o_Activity_info"> + <div class="o_Activity_dueDateText" + t-att-class="{ + 'o-default': activity.state === 'default', + 'o-overdue': activity.state === 'overdue', + 'o-planned': activity.state === 'planned', + 'o-today': activity.state === 'today', + }" + > + <t t-esc="delayLabel"/> + </div> + <t t-if="activity.summary"> + <div class="o_Activity_summary"> + <t t-esc="summary"/> + </div> + </t> + <t t-elif="activity.type"> + <div class="o_Activity_summary o_Activity_type"> + <t t-esc="activity.type.displayName"/> + </div> + </t> + <t t-if="activity.assignee"> + <div class="o_Activity_userName"> + <t t-esc="assignedUserText"/> + </div> + </t> + <a class="o_Activity_detailsButton btn btn-link" t-on-click="_onClickDetailsButton" role="button"> + <i class="fa fa-info-circle" role="img" title="Info"/> + </a> + </div> + + <t t-if="state.areDetailsVisible"> + <div class="o_Activity_details"> + <dl class="dl-horizontal"> + <t t-if="activity.type"> + <dt>Activity type</dt> + <dd class="o_Activity_type"> + <t t-esc="activity.type.displayName"/> + </dd> + </t> + <t t-if="activity.creator"> + <dt>Created</dt> + <dd class="o_Activity_detailsCreation"> + <t t-esc="formattedCreateDatetime"/> + <img class="o_Activity_detailsUserAvatar o_Activity_detailsCreatorAvatar" t-attf-src="/web/image/res.users/{{ activity.creator.id }}/image_128" t-att-title="activity.creator.nameOrDisplayName" t-att-alt="activity.creator.nameOrDisplayName"/> + <span class="o_Activity_detailsCreator"> + <t t-esc="activity.creator.nameOrDisplayName"/> + </span> + </dd> + </t> + <t t-if="activity.assignee"> + <dt>Assigned to</dt> + <dd class="o_Activity_detailsAssignation"> + <img class="o_Activity_detailsUserAvatar o_Activity_detailsAssignationUserAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-title="activity.assignee.nameOrDisplayName" t-att-alt="activity.assignee.nameOrDisplayName"/> + <t t-esc="activity.assignee.nameOrDisplayName"/> + </dd> + </t> + <dt>Due on</dt> + <dd class="o_Activity_detailsDueDate"> + <span class="o_Activity_deadlineDateText" + t-att-class="{ + 'o-default': activity.state === 'default', + 'o-overdue': activity.state === 'overdue', + 'o-planned': activity.state === 'planned', + 'o-today': activity.state === 'today', + }" + > + <t t-esc="formattedDeadlineDate"/> + </span> + </dd> + </dl> + </div> + </t> + + <t t-if="activity.note"> + <div class="o_Activity_note"> + <t t-raw="activity.note"/> + </div> + </t> + + <t t-if="activity.mailTemplates.length > 0"> + <div class="o_Activity_mailTemplates"> + <t t-foreach="activity.mailTemplates" t-as="mailTemplate" t-key="mailTemplate.localId"> + <MailTemplate + class="o_Activity_mailTemplate" + activityLocalId="activity.localId" + mailTemplateLocalId="mailTemplate.localId" + /> + </t> + </div> + </t> + + <t t-if="activity.canWrite"> + <div name="tools" class="o_Activity_tools"> + <t t-if="activity.category !== 'upload_file'"> + <Popover position="'right'" title="MARK_DONE"> + <button class="o_Activity_toolButton o_Activity_markDoneButton btn btn-link" t-att-title="MARK_DONE"> + <i class="fa fa-check"/> Mark Done + </button> + <t t-set="opened"> + <ActivityMarkDonePopover activityLocalId="props.activityLocalId"/> + </t> + </Popover> + </t> + <t t-else=""> + <button class="o_Activity_toolButton o_Activity_uploadButton btn btn-link" t-on-click="_onClickUploadDocument"> + <i class="fa fa-upload"/> Upload Document + </button> + <FileUploader + attachmentLocalIds="activity.attachments.map(attachment => attachment.localId)" + uploadId="activity.thread.id" + uploadModel="activity.thread.model" + t-on-o-attachment-created="_onAttachmentCreated" + t-ref="fileUploader" + /> + </t> + <button class="o_Activity_toolButton o_Activity_editButton btn btn-link" t-on-click="_onClickEdit"> + <i class="fa fa-pencil"/> Edit + </button> + <button class="o_Activity_toolButton o_Activity_cancelButton btn btn-link" t-on-click="_onClickCancel" > + <i class="fa fa-times"/> Cancel + </button> + </div> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity/activity_tests.js b/addons/mail/static/src/components/activity/activity_tests.js new file mode 100644 index 00000000..1c260f07 --- /dev/null +++ b/addons/mail/static/src/components/activity/activity_tests.js @@ -0,0 +1,1157 @@ +odoo.define('mail/static/src/components/activity/activity_tests.js', function (require) { +'use strict'; + +const components = { + Activity: require('mail/static/src/components/activity/activity.js'), +}; + +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Bus = require('web.Bus'); +const { date_to_str } = require('web.time'); + +const { Component, tags: { xml } } = owl; + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('activity', {}, function () { +QUnit.module('activity_tests.js', { + beforeEach() { + beforeEach(this); + + this.createActivityComponent = async function (activity) { + await createRootComponent(this, components.Activity, { + props: { activityLocalId: activity.localId }, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('activity simplest layout', async function (assert) { + assert.expect(12); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_sidebar').length, + 1, + "should have activity sidebar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_core').length, + 1, + "should have activity core" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_user').length, + 1, + "should have activity user" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_info').length, + 1, + "should have activity info" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_note').length, + 0, + "should not have activity note" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "should not have activity details" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplates').length, + 0, + "should not have activity mail templates" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_editButton').length, + 0, + "should not have activity Edit button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_cancelButton').length, + 0, + "should not have activity Cancel button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_markDoneButton').length, + 0, + "should not have activity Mark as Done button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_uploadButton').length, + 0, + "should not have activity Upload button" + ); +}); + +QUnit.test('activity with note layout', async function (assert) { + assert.expect(3); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + id: 12, + note: 'There is no good or bad note', + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_note').length, + 1, + "should have activity note" + ); + assert.strictEqual( + document.querySelector('.o_Activity_note').textContent, + "There is no good or bad note", + "activity note should be 'There is no good or bad note'" + ); +}); + +QUnit.test('activity info layout when planned after tomorrow', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const fiveDaysFromNow = new Date(); + fiveDaysFromNow.setDate(today.getDate() + 5); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(fiveDaysFromNow), + id: 12, + state: 'planned', + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'), + "activity delay should have the right color modifier class (planned)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Due in 5 days:", + "activity delay should have 'Due in 5 days:' as label" + ); +}); + +QUnit.test('activity info layout when planned tomorrow', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(tomorrow), + id: 12, + state: 'planned', + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'), + "activity delay should have the right color modifier class (planned)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + 'Tomorrow:', + "activity delay should have 'Tomorrow:' as label" + ); +}); + +QUnit.test('activity info layout when planned today', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(today), + id: 12, + state: 'today', + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-today'), + "activity delay should have the right color modifier class (today)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Today:", + "activity delay should have 'Today:' as label" + ); +}); + +QUnit.test('activity info layout when planned yesterday', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(yesterday), + id: 12, + state: 'overdue', + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'), + "activity delay should have the right color modifier class (overdue)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Yesterday:", + "activity delay should have 'Yesterday:' as label" + ); +}); + +QUnit.test('activity info layout when planned before yesterday', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const fiveDaysBeforeNow = new Date(); + fiveDaysBeforeNow.setDate(today.getDate() - 5); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(fiveDaysBeforeNow), + id: 12, + state: 'overdue', + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'), + "activity delay should have the right color modifier class (overdue)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "5 days overdue:", + "activity delay should have '5 days overdue:' as label" + ); +}); + +QUnit.test('activity with a summary layout', async function (assert) { + assert.expect(4); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + id: 12, + summary: 'test summary', + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary').length, + 1, + "should have activity summary" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_type').length, + 0, + "should not have the activity type as summary" + ); + assert.strictEqual( + document.querySelector('.o_Activity_summary').textContent.trim(), + "“test summary”", + "should have the specific activity summary in activity summary" + ); +}); + +QUnit.test('activity without summary layout', async function (assert) { + assert.expect(5); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_type').length, + 1, + "activity details should have an activity type section" + ); + assert.strictEqual( + document.querySelector('.o_Activity_type').textContent.trim(), + "Fake type", + "activity details should have the activity type display name in type section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary.o_Activity_type').length, + 1, + "should have activity type as summary" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary:not(.o_Activity_type)').length, + 0, + "should not have a specific summary" + ); +}); + +QUnit.test('activity details toggle', async function (assert) { + assert.expect(5); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + creator: [['insert', { id: 1, display_name: "Admin" }]], + dateCreate: date_to_str(today), + dateDeadline: date_to_str(tomorrow), + id: 12, + state: 'planned', + thread: [['insert', { id: 42, model: 'res.partner' }]], + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "activity details should not be visible by default" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsButton').length, + 1, + "activity should have a details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 1, + "activity details should be visible after clicking on details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "activity details should no longer be visible after clicking again on details button" + ); +}); + +QUnit.test('activity details layout', async function (assert) { + assert.expect(11); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + assignee: [['insert', { id: 10, display_name: "Pauvre pomme" }]], + creator: [['insert', { id: 1, display_name: "Admin" }]], + dateCreate: date_to_str(today), + dateDeadline: date_to_str(tomorrow), + id: 12, + state: 'planned', + thread: [['insert', { id: 42, model: 'res.partner' }]], + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_userAvatar').length, + 1, + "should have activity user avatar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsButton').length, + 1, + "activity should have a details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 1, + "activity details should be visible after clicking on details button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details .o_Activity_type').length, + 1, + "activity details should have type" + ); + assert.strictEqual( + document.querySelector('.o_Activity_details .o_Activity_type').textContent, + "Fake type", + "activity details type should be 'Fake type'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsCreation').length, + 1, + "activity details should have creation date " + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsCreator').length, + 1, + "activity details should have creator" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsAssignation').length, + 1, + "activity details should have assignation information" + ); + assert.strictEqual( + document.querySelector('.o_Activity_detailsAssignation').textContent.indexOf('Pauvre pomme'), + 0, + "activity details assignation information should contain creator display name" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsAssignationUserAvatar').length, + 1, + "activity details should have user avatar" + ); +}); + +QUnit.test('activity with mail template layout', async function (assert) { + assert.expect(8); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + id: 12, + mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]], + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_sidebar').length, + 1, + "should have activity sidebar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplates').length, + 1, + "should have activity mail templates" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplate').length, + 1, + "should have activity mail template" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_name').length, + 1, + "should have activity mail template name" + ); + assert.strictEqual( + document.querySelector('.o_MailTemplate_name').textContent, + "Dummy mail template", + "should have activity mail template name" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_preview').length, + 1, + "should have activity mail template name preview button" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_send').length, + 1, + "should have activity mail template name send button" + ); +}); + +QUnit.test('activity with mail template: preview mail', async function (assert) { + assert.expect(10); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_model, + 'res.partner', + 'Action should have the activity res model as default model in context' + ); + assert.ok( + payload.action.context.default_use_template, + 'Action should have true as default use_template in context' + ); + assert.strictEqual( + payload.action.context.default_template_id, + 1, + 'Action should have the selected mail template id as default template id in context' + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + "mail.compose.message", + 'Action should have "mail.compose.message" as res_model' + ); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create({ + id: 12, + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_preview').length, + 1, + "should have activity mail template name preview button" + ); + + document.querySelector('.o_MailTemplate_preview').click(); + assert.verifySteps( + ['do_action'], + "should have called 'compose email' action correctly" + ); +}); + +QUnit.test('activity with mail template: send mail', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'activity_send_mail') { + assert.step('activity_send_mail'); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 42); + assert.strictEqual(args.args[1], 1); + return; + } else { + return this._super(...arguments); + } + }, + }); + const activity = this.env.models['mail.activity'].create({ + id: 12, + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_send').length, + 1, + "should have activity mail template name send button" + ); + + document.querySelector('.o_MailTemplate_send').click(); + assert.verifySteps( + ['activity_send_mail'], + "should have called activity_send_mail rpc" + ); +}); + +QUnit.test('activity upload document is available', async function (assert) { + assert.expect(3); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_uploadButton').length, + 1, + "should have activity upload button" + ); + assert.strictEqual( + document.querySelectorAll('.o_FileUploader').length, + 1, + "should have a file uploader" + ); +}); + +QUnit.test('activity click on mark as done', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_markDoneButton').length, + 1, + "should have activity Mark as Done button" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ActivityMarkDonePopover').length, + 1, + "should have opened the mark done popover" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ActivityMarkDonePopover').length, + 0, + "should have closed the mark done popover" + ); +}); + +QUnit.test('activity mark as done popover should focus feedback input on open [REQUIRE FOCUS]', async function (assert) { + assert.expect(3); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + + assert.containsOnce( + document.body, + '.o_Activity', + "should have activity component" + ); + assert.containsOnce( + document.body, + '.o_Activity_markDoneButton', + "should have activity Mark as Done button" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.strictEqual( + document.querySelector('.o_ActivityMarkDonePopover_feedback'), + document.activeElement, + "the popover textarea should have the focus" + ); +}); + +QUnit.test('activity click on edit', async function (assert) { + assert.expect(9); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + 'Action should have the activity res model as default res model in context' + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + "mail.activity", + 'Action should have "mail.activity" as res_model' + ); + assert.strictEqual( + payload.action.res_id, + 12, + 'Action should have activity id as res_id' + ); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + id: 12, + mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]], + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_editButton').length, + 1, + "should have activity edit button" + ); + + document.querySelector('.o_Activity_editButton').click(); + assert.verifySteps( + ['do_action'], + "should have called 'schedule activity' action correctly" + ); +}); + +QUnit.test('activity edition', async function (assert) { + assert.expect(14); + + this.data['mail.activity'].records.push({ + can_write: true, + icon: 'fa-times', + id: 12, + res_id: 42, + res_model: 'res.partner', + }); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + 'Action should have the activity res model as default res model in context' + ); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + 'mail.activity', + 'Action should have "mail.activity" as res_model' + ); + assert.strictEqual( + payload.action.res_id, + 12, + 'Action should have activity id as res_id' + ); + this.data['mail.activity'].records[0].icon = 'fa-check'; + payload.options.on_close(); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].insert( + this.env.models['mail.activity'].convertData( + this.data['mail.activity'].records[0] + ) + ); + await this.createActivityComponent(activity); + + assert.containsOnce( + document.body, + '.o_Activity', + "should have activity component" + ); + assert.containsOnce( + document.body, + '.o_Activity_editButton', + "should have activity edit button" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon', + "should have activity icon" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon.fa-times', + "should have initial activity icon" + ); + assert.containsNone( + document.body, + '.o_Activity_icon.fa-check', + "should not have new activity icon when not edited yet" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_editButton').click(); + }); + assert.verifySteps( + ['do_action'], + "should have called 'schedule activity' action correctly" + ); + assert.containsNone( + document.body, + '.o_Activity_icon.fa-times', + "should no more have initial activity icon once edited" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon.fa-check', + "should now have new activity icon once edited" + ); +}); + +QUnit.test('activity click on cancel', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + assert.step('unlink'); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + return; + } else { + return this._super(...arguments); + } + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + id: 12, + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + + // Create a parent component to surround the Activity component in order to be able + // to check that activity component has been destroyed + class ParentComponent extends Component { + constructor(...args) { + super(... args); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + }; + }); + } + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + } + ParentComponent.env = this.env; + Object.assign(ParentComponent, { + components, + props: { activityLocalId: String }, + template: xml` + <div> + <p>parent</p> + <t t-if="activity"> + <Activity activityLocalId="activity.localId"/> + </t> + </div> + `, + }); + await createRootComponent(this, ParentComponent, { + props: { activityLocalId: activity.localId }, + target: this.widget.el, + }); + + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_cancelButton').length, + 1, + "should have activity cancel button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_cancelButton').click() + ); + assert.verifySteps( + ['unlink'], + "should have called unlink rpc after clicking on cancel" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 0, + "should no longer display activity after clicking on cancel" + ); +}); + +QUnit.test('activity mark done popover close on ESCAPE', async function (assert) { + // This test is not in activity_mark_done_popover_tests.js as it requires the activity mark done + // component to have a parent in order to allow testing interactions the popover. + assert.expect(2); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + + await this.createActivityComponent(activity); + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_ActivityMarkDonePopover`).dispatchEvent(ev); + }); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover', + "ESCAPE pressed should have closed the mark done popover" + ); +}); + +QUnit.test('activity mark done popover click on discard', async function (assert) { + // This test is not in activity_mark_done_popover_tests.js as it requires the activity mark done + // component to have a parent in order to allow testing interactions the popover. + assert.expect(3); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should contain the discard button" + ); + await afterNextRender(() => + document.querySelector('.o_ActivityMarkDonePopover_discardButton').click() + ); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover', + "Discard button clicked should have closed the mark done popover" + ); +}); + +QUnit.test('data-oe-id & data-oe-model link redirection on click', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should open view" + ); + assert.strictEqual( + payload.action.res_model, + 'some.model', + "action should open view on 'some.model' model" + ); + assert.strictEqual( + payload.action.res_id, + 250, + "action should open view on 250" + ); + assert.step('do-action:openFormView_some.model_250'); + }); + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + note: `<p><a href="#" data-oe-id="250" data-oe-model="some.model">some.model_250</a></p>`, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityComponent(activity); + assert.containsOnce( + document.body, + '.o_Activity_note', + "activity should have a note" + ); + assert.containsOnce( + document.querySelector('.o_Activity_note'), + 'a', + "activity note should have a link" + ); + + document.querySelector(`.o_Activity_note a`).click(); + assert.verifySteps( + ['do-action:openFormView_some.model_250'], + "should have open form view on related record after click on link" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/activity_box/activity_box.js b/addons/mail/static/src/components/activity_box/activity_box.js new file mode 100644 index 00000000..ca191694 --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.js @@ -0,0 +1,64 @@ +odoo.define('mail/static/src/components/activity_box/activity_box.js', function (require) { +'use strict'; + +const components = { + Activity: require('mail/static/src/components/activity/activity.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ActivityBox extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + const thread = chatter && chatter.thread; + return { + chatter: chatter ? chatter.__state : undefined, + thread: thread && thread.__state, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickTitle() { + this.chatter.toggleActivityBoxVisibility(); + } + +} + +Object.assign(ActivityBox, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.ActivityBox', +}); + +return ActivityBox; + +}); diff --git a/addons/mail/static/src/components/activity_box/activity_box.scss b/addons/mail/static/src/components/activity_box/activity_box.scss new file mode 100644 index 00000000..64e99347 --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.scss @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ActivityBox_title { + display: flex; + align-items: center; + flex: 0 0 auto; + margin-top: map-get($spacers, 4); + margin-bottom: map-get($spacers, 4); +} + +.o_ActivityBox_titleBadge { + padding: map-get($spacers, 0) map-get($spacers, 2); +} + +.o_ActivityBox_titleBadges { + margin-inline-end: map-get($spacers, 3); +} + +.o_ActivityBox_titleLine { + flex: 1 1 auto; + width: auto; +} + +.o_ActivityBox_titleText { + margin: map-get($spacers, 0) map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ActivityBox_title { + font-weight: bold; +} + +.o_ActivityBox_titleBadge { + font-size: 11px; +} + +.o_ActivityBox_titleLine { + border-color: gray('400'); + border-style: dashed; +} diff --git a/addons/mail/static/src/components/activity_box/activity_box.xml b/addons/mail/static/src/components/activity_box/activity_box.xml new file mode 100644 index 00000000..900b5634 --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ActivityBox" owl="1"> + <div class="o_ActivityBox"> + <t t-if="chatter and chatter.thread"> + <a role="button" class="o_ActivityBox_title btn" t-on-click="_onClickTitle"> + <hr class="o_ActivityBox_titleLine" /> + <span class="o_ActivityBox_titleText"> + <i class="fa fa-fw" t-att-class="chatter.isActivityBoxVisible ? 'fa-caret-down' : 'fa-caret-right'"/> + Planned activities + </span> + <t t-if="!chatter.isActivityBoxVisible"> + <span class="o_ActivityBox_titleBadges"> + <t t-if="chatter.thread.overdueActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-danger"> + <t t-esc="chatter.thread.overdueActivities.length"/> + </span> + </t> + <t t-if="chatter.thread.todayActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-warning"> + <t t-esc="chatter.thread.todayActivities.length"/> + </span> + </t> + <t t-if="chatter.thread.futureActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-success"> + <t t-esc="chatter.thread.futureActivities.length"/> + </span> + </t> + </span> + </t> + <hr class="o_ActivityBox_titleLine" /> + </a> + <t t-if="chatter.isActivityBoxVisible"> + <div class="o_ActivityList"> + <t t-foreach="chatter.thread.activities" t-as="activity" t-key="activity.localId"> + <Activity class="o_ActivityBox_activity" activityLocalId="activity.localId"/> + </t> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js new file mode 100644 index 00000000..de1ea5ce --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js @@ -0,0 +1,122 @@ +odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ActivityMarkDonePopover extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + }; + }); + this._feedbackTextareaRef = useRef('feedbackTextarea'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + mounted() { + this._feedbackTextareaRef.el.focus(); + if (this.activity.feedbackBackup) { + this._feedbackTextareaRef.el.value = this.activity.feedbackBackup; + } + } + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {string} + */ + get DONE_AND_SCHEDULE_NEXT() { + return this.env._t("Done & Schedule Next"); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _close() { + this.trigger('o-popover-close'); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onBlur() { + this.activity.update({ + feedbackBackup: this._feedbackTextareaRef.el.value, + }); + } + + /** + * @private + */ + _onClickDiscard() { + this._close(); + } + + /** + * @private + */ + async _onClickDone() { + await this.activity.markAsDone({ + feedback: this._feedbackTextareaRef.el.value, + }); + this.trigger('reload', { keepChanges: true }); + } + + /** + * @private + */ + _onClickDoneAndScheduleNext() { + this.activity.markAsDoneAndScheduleNext({ + feedback: this._feedbackTextareaRef.el.value, + }); + } + + /** + * @private + */ + _onKeydown(ev) { + if (ev.key === 'Escape') { + this._close(); + } + } + +} + +Object.assign(ActivityMarkDonePopover, { + props: { + activityLocalId: String, + }, + template: 'mail.ActivityMarkDonePopover', +}); + +return ActivityMarkDonePopover; + +}); diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss new file mode 100644 index 00000000..3479ffc3 --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ActivityMarkDonePopover { + min-height: 100px; +} + +.o_ActivityMarkDonePopover_buttons { + margin-top: map-get($spacers, 2); +} + +.o_ActivityMarkDonePopover_doneButton { + margin: map-get($spacers, 0) map-get($spacers, 2); +} + +.o_ActivityMarkDonePopover_feedback { + min-height: 70px; +} + diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml new file mode 100644 index 00000000..357ab59b --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ActivityMarkDonePopover" owl="1"> + <div class="o_ActivityMarkDonePopover" t-on-keydown="_onKeydown"> + <t t-if="activity"> + <textarea class="form-control o_ActivityMarkDonePopover_feedback" rows="3" placeholder="Write Feedback" t-on-blur="_onBlur" t-ref="feedbackTextarea"/> + <div class="o_ActivityMarkDonePopover_buttons"> + <button type="button" class="o_ActivityMarkDonePopover_doneScheduleNextButton btn btn-sm btn-primary" t-on-click="_onClickDoneAndScheduleNext" t-esc="DONE_AND_SCHEDULE_NEXT"/> + <t t-if="!activity.force_next"> + <button type="button" class="o_ActivityMarkDonePopover_doneButton btn btn-sm btn-primary" t-on-click="_onClickDone"> + Done + </button> + </t> + <button type="button" class="o_ActivityMarkDonePopover_discardButton btn btn-sm btn-link" t-on-click="_onClickDiscard"> + Discard + </button> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js new file mode 100644 index 00000000..0c019b2b --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js @@ -0,0 +1,297 @@ +odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js', function (require) { +'use strict'; + +const components = { + ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'), +}; + +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('activity_mark_done_popover', {}, function () { +QUnit.module('activity_mark_done_popover_tests.js', { + beforeEach() { + beforeEach(this); + + this.createActivityMarkDonePopoverComponent = async activity => { + await createRootComponent(this, components.ActivityMarkDonePopover, { + props: { activityLocalId: activity.localId }, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('activity mark done popover simplest layout', async function (assert) { + assert.expect(6); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityMarkDonePopoverComponent(activity); + + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_feedback', + "Popover component should contain the feedback textarea" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_buttons', + "Popover component should contain the action buttons" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneScheduleNextButton', + "Popover component should contain the done & schedule next button" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneButton', + "Popover component should contain the done button" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should contain the discard button" + ); +}); + +QUnit.test('activity with force next mark done popover simplest layout', async function (assert) { + assert.expect(6); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + force_next: true, + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityMarkDonePopoverComponent(activity); + + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_feedback', + "Popover component should contain the feedback textarea" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_buttons', + "Popover component should contain the action buttons" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneScheduleNextButton', + "Popover component should contain the done & schedule next button" + ); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover_doneButton', + "Popover component should NOT contain the done button" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should contain the discard button" + ); +}); + +QUnit.test('activity mark done popover mark done without feedback', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { + assert.step('action_feedback'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.attachment_ids.length, 0); + assert.notOk(args.kwargs.feedback); + return; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityMarkDonePopoverComponent(activity); + + document.querySelector('.o_ActivityMarkDonePopover_doneButton').click(); + assert.verifySteps( + ['action_feedback'], + "Mark done and schedule next button should call the right rpc" + ); +}); + +QUnit.test('activity mark done popover mark done with feedback', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { + assert.step('action_feedback'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.attachment_ids.length, 0); + assert.strictEqual(args.kwargs.feedback, 'This task is done'); + return; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityMarkDonePopoverComponent(activity); + + let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback'); + feedbackTextarea.focus(); + document.execCommand('insertText', false, 'This task is done'); + document.querySelector('.o_ActivityMarkDonePopover_doneButton').click(); + assert.verifySteps( + ['action_feedback'], + "Mark done and schedule next button should call the right rpc" + ); +}); + +QUnit.test('activity mark done popover mark done and schedule next', async function (assert) { + assert.expect(6); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('activity_action'); + throw new Error("The do-action event should not be triggered when the route doesn't return an action"); + }); + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback_schedule_next') { + assert.step('action_feedback_schedule_next'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.feedback, 'This task is done'); + return false; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + env: { bus }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityMarkDonePopoverComponent(activity); + + let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback'); + feedbackTextarea.focus(); + document.execCommand('insertText', false, 'This task is done'); + await afterNextRender(() => { + document.querySelector('.o_ActivityMarkDonePopover_doneScheduleNextButton').click(); + }); + assert.verifySteps( + ['action_feedback_schedule_next'], + "Mark done and schedule next button should call the right rpc and not trigger an action" + ); +}); + +QUnit.test('[technical] activity mark done & schedule next with new action', async function (assert) { + assert.expect(3); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('activity_action'); + assert.deepEqual( + payload.action, + { type: 'ir.actions.act_window' }, + "The content of the action should be correct" + ); + }); + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback_schedule_next') { + return { type: 'ir.actions.act_window' }; + } + return this._super(...arguments); + }, + env: { bus }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + thread: [['insert', { id: 42, model: 'res.partner' }]], + }); + await this.createActivityMarkDonePopoverComponent(activity); + + await afterNextRender(() => { + document.querySelector('.o_ActivityMarkDonePopover_doneScheduleNextButton').click(); + }); + assert.verifySteps( + ['activity_action'], + "The action returned by the route should be executed" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment/attachment.js b/addons/mail/static/src/components/attachment/attachment.js new file mode 100644 index 00000000..a4b7b136 --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.js @@ -0,0 +1,204 @@ +odoo.define('mail/static/src/components/attachment/attachment.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + AttachmentDeleteConfirmDialog: require('mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js'), +}; + +const { Component, useState } = owl; + +class Attachment extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + attachmentLocalIds: 1, + }, + }); + useStore(props => { + const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId); + return { + attachment: attachment ? attachment.__state : undefined, + }; + }); + this.state = useState({ + hasDeleteConfirmDialog: false, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.attachment} + */ + get attachment() { + return this.env.models['mail.attachment'].get(this.props.attachmentLocalId); + } + + /** + * Return the url of the attachment. Temporary attachments, a.k.a. uploading + * attachments, do not have an url. + * + * @returns {string} + */ + get attachmentUrl() { + if (this.attachment.isTemporary) { + return ''; + } + return this.env.session.url('/web/content', { + id: this.attachment.id, + download: true, + }); + } + + /** + * Get the details mode after auto mode is computed + * + * @returns {string} 'card', 'hover' or 'none' + */ + get detailsMode() { + if (this.props.detailsMode !== 'auto') { + return this.props.detailsMode; + } + if (this.attachment.fileType !== 'image') { + return 'card'; + } + return 'hover'; + } + + /** + * Get the attachment representation style to be applied + * + * @returns {string} + */ + get imageStyle() { + if (this.attachment.fileType !== 'image') { + return ''; + } + if (this.env.isQUnitTest) { + // background-image:url is hardly mockable, and attachments in + // QUnit tests do not actually exist in DB, so style should not + // be fetched at all. + return ''; + } + let size; + if (this.detailsMode === 'card') { + size = '38x38'; + } else { + // The size of background-image depends on the props.imageSize + // to sync with width and height of `.o_Attachment_image`. + if (this.props.imageSize === "large") { + size = '400x400'; + } else if (this.props.imageSize === "medium") { + size = '200x200'; + } else if (this.props.imageSize === "small") { + size = '100x100'; + } + } + // background-size set to override value from `o_image` which makes small image stretched + return `background-image:url(/web/image/${this.attachment.id}/${size}); background-size: auto;`; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Download the attachment when clicking on donwload icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDownload(ev) { + ev.stopPropagation(); + window.location = `/web/content/ir.attachment/${this.attachment.id}/datas?download=true`; + } + + /** + * Open the attachment viewer when clicking on viewable attachment. + * + * @private + * @param {MouseEvent} ev + */ + _onClickImage(ev) { + if (!this.attachment.isViewable) { + return; + } + this.env.models['mail.attachment'].view({ + attachment: this.attachment, + attachments: this.props.attachmentLocalIds.map( + attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId) + ), + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnlink(ev) { + ev.stopPropagation(); + if (!this.attachment) { + return; + } + if (this.attachment.isLinkedToComposer) { + this.attachment.remove(); + this.trigger('o-attachment-removed', { attachmentLocalId: this.props.attachmentLocalId }); + } else { + this.state.hasDeleteConfirmDialog = true; + } + } + + /** + * @private + */ + _onDeleteConfirmDialogClosed() { + this.state.hasDeleteConfirmDialog = false; + } +} + +Object.assign(Attachment, { + components, + defaultProps: { + attachmentLocalIds: [], + detailsMode: 'auto', + imageSize: 'medium', + isDownloadable: false, + isEditable: true, + showExtension: true, + showFilename: true, + }, + props: { + attachmentLocalId: String, + attachmentLocalIds: { + type: Array, + element: String, + }, + detailsMode: { + type: String, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + imageSize: { + type: String, + validate: prop => ['small', 'medium', 'large'].includes(prop), + }, + isDownloadable: Boolean, + isEditable: Boolean, + showExtension: Boolean, + showFilename: Boolean, + }, + template: 'mail.Attachment', +}); + +return Attachment; + +}); diff --git a/addons/mail/static/src/components/attachment/attachment.scss b/addons/mail/static/src/components/attachment/attachment.scss new file mode 100644 index 00000000..583e5703 --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.scss @@ -0,0 +1,204 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Attachment { + display: flex; + + &:hover .o_Attachment_asideItemUnlink.o-pretty { + transform: translateX(0); + } +} + +.o_Attachment_action { + min-width: 20px; +} + +.o_Attachment_actions { + justify-content: space-between; + display: flex; + flex-direction: column; +} + +.o_Attachment_aside { + position: relative; + overflow: hidden; + + &:not(.o-has-multiple-action) { + min-width: 50px; + } + + &.o-has-multiple-action { + min-width: 30px; + display: flex; + flex-direction: column; + } +} + +.o_Attachment_asideItem { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; +} + +.o_Attachment_asideItemUnlink.o-pretty { + position: absolute; + top: 0; + transform: translateX(100%); +} + +.o_Attachment_details { + display: flex; + flex-flow: column; + justify-content: center; + min-width: 0; /* This allows the text ellipsis in the flex element */ + /* prevent hover delete button & attachment image to be too close to the text */ + padding-left : map-get($spacers, 1); + padding-right : map-get($spacers, 1); +} + +.o_Attachment_filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.o_Attachment_image { + flex-shrink: 0; + margin: 3px; + + &.o-details-overlay { + position: relative; + // small, medium and large size styles should be sync with + // the size of the background-image and `.o_Attachment_image`. + &.o-small { + min-width: 100px; + min-height: 100px; + } + &.o-medium { + min-width: 200px; + min-height: 200px; + } + &.o-large { + min-width: 400px; + min-height: 400px; + } + + &:hover { + .o_Attachment_imageOverlay { + opacity: 1; + } + } + } +} + +.o_Attachment_imageOverlay { + bottom: 0; + display:flex; + flex-direction: row; + justify-content: flex-end; + left: 0; + padding: 10px; + position: absolute; + right: 0; + top: 0; +} + +.o_Attachment_imageOverlayDetails { + display: flex; + flex-direction: column; + justify-content: flex-end; + margin: 3px; + width: 200px; +} + + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Attachment { + &.o-has-card-details { + background-color: gray('300'); + border-radius: 5px; + } +} + +.o_Attachment_action { + border-radius: 10px; + cursor: pointer; + text-align: center; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +} + +.o_Attachment_aside { + border-radius: 0 5px 5px 0; +} + +.o_Attachment_asideItemDownload { + cursor: pointer; + + &:hover { + background-color: gray('400'); + } +} + +.o_Attachment_asideItemUnlink { + cursor: pointer; + + &:not(.o-pretty):hover { + background-color: gray('400'); + } + + &.o-pretty { + color: white; + background-color: $o-brand-primary; + + &:hover { + background-color: darken($o-brand-primary, 10%); + } + } + +} + +.o_Attachment_asideItemUploaded { + color: $o-brand-primary; +} + +.o_Attachment_extension { + text-transform: uppercase; + font-size: 80%; + font-weight: 400; +} + +.o_Attachment_image.o-attachment-viewable { + cursor: zoom-in; + + &:not(.o-details-overlay):hover { + opacity: 0.7; + } +} + +.o_Attachment_imageOverlay { + background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.9)); + border-radius: 5px; + color: white; + opacity: 0; +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_Attachment_asideItemUnlink.o-pretty { + transition: transform 0.3s ease 0s; +} + +.o_Attachment_imageOverlay { + transition: all 0.3s ease 0s; +} diff --git a/addons/mail/static/src/components/attachment/attachment.xml b/addons/mail/static/src/components/attachment/attachment.xml new file mode 100644 index 00000000..938ff894 --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.xml @@ -0,0 +1,115 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Attachment" owl="1"> + <div class="o_Attachment" + t-att-class="{ + 'o-downloadable': props.isDownloadable, + 'o-editable': props.isEditable, + 'o-has-card-details': attachment and detailsMode === 'card', + 'o-temporary': attachment and attachment.isTemporary, + 'o-viewable': attachment and attachment.isViewable, + }" t-att-title="attachment ? attachment.displayName : undefined" t-att-data-attachment-local-id="attachment ? attachment.localId : undefined" + > + <t t-if="attachment"> + <!-- Image style--> + <!-- o_image from mimetype.scss --> + <div class="o_Attachment_image o_image" t-on-click="_onClickImage" + t-att-class="{ + 'o-attachment-viewable': attachment.isViewable, + 'o-details-overlay': detailsMode !== 'card', + 'o-large': props.imageSize === 'large', + 'o-medium': props.imageSize === 'medium', + 'o-small': props.imageSize === 'small', + }" t-att-href="attachmentUrl" t-att-style="imageStyle" t-att-data-mimetype="attachment.mimetype" + > + <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'hover'"> + <div class="o_Attachment_imageOverlay"> + <div class="o_Attachment_details o_Attachment_imageOverlayDetails"> + <t t-if="props.showFilename"> + <div class="o_Attachment_filename"> + <t t-esc="attachment.displayName"/> + </div> + </t> + <t t-if="props.showExtension"> + <div class="o_Attachment_extension"> + <t t-esc="attachment.extension"/> + </div> + </t> + </div> + <div class="o_Attachment_actions"> + <!-- Remove button --> + <t t-if="props.isEditable" t-key="'unlink'"> + <div class="o_Attachment_action o_Attachment_actionUnlink" + t-att-class="{ + 'o-pretty': attachment.isLinkedToComposer, + }" t-on-click="_onClickUnlink" title="Remove" + > + <i class="fa fa-times"/> + </div> + </t> + <!-- Download button --> + <t t-if="props.isDownloadable and !attachment.isTemporary" t-key="'download'"> + <div class="o_Attachment_action o_Attachment_actionDownload" t-on-click="_onClickDownload" title="Download"> + <i class="fa fa-download"/> + </div> + </t> + </div> + </div> + </t> + </div> + <!-- Attachment details --> + <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'card'"> + <div class="o_Attachment_details"> + <t t-if="props.showFilename"> + <div class="o_Attachment_filename"> + <t t-esc="attachment.displayName"/> + </div> + </t> + <t t-if="props.showExtension"> + <div class="o_Attachment_extension"> + <t t-esc="attachment.extension"/> + </div> + </t> + </div> + </t> + <!-- Attachment aside --> + <t t-if="detailsMode !== 'hover' and (props.isDownloadable or props.isEditable)"> + <div class="o_Attachment_aside" t-att-class="{ 'o-has-multiple-action': props.isDownloadable and props.isEditable }"> + <!-- Uploading icon --> + <t t-if="attachment.isTemporary and attachment.isLinkedToComposer"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUploading" title="Uploading"> + <i class="fa fa-spin fa-spinner"/> + </div> + </t> + <!-- Uploaded icon --> + <t t-if="!attachment.isTemporary and attachment.isLinkedToComposer"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUploaded" title="Uploaded"> + <i class="fa fa-check"/> + </div> + </t> + <!-- Remove button --> + <t t-if="props.isEditable"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUnlink" t-att-class="{ 'o-pretty': attachment.isLinkedToComposer }" t-on-click="_onClickUnlink" title="Remove"> + <i class="fa fa-times"/> + </div> + </t> + <!-- Download button --> + <t t-if="props.isDownloadable and !attachment.isTemporary"> + <div class="o_Attachment_asideItem o_Attachment_asideItemDownload" t-on-click="_onClickDownload" title="Download"> + <i class="fa fa-download"/> + </div> + </t> + </div> + </t> + <t t-if="state.hasDeleteConfirmDialog"> + <AttachmentDeleteConfirmDialog + attachmentLocalId="props.attachmentLocalId" + t-on-dialog-closed="_onDeleteConfirmDialogClosed" + /> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment/attachment_tests.js b/addons/mail/static/src/components/attachment/attachment_tests.js new file mode 100644 index 00000000..eaeb267d --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment_tests.js @@ -0,0 +1,762 @@ +odoo.define('mail/static/src/components/attachment/attachment_tests.js', function (require) { +'use strict'; + +const components = { + Attachment: require('mail/static/src/components/attachment/attachment.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('attachment', {}, function () { +QUnit.module('attachment_tests.js', { + beforeEach() { + beforeEach(this); + + this.createAttachmentComponent = async (attachment, otherProps) => { + const props = Object.assign({ attachmentLocalId: attachment.localId }, otherProps); + await createRootComponent(this, components.Attachment, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('simplest layout', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + const attachmentEl = document.querySelector('.o_Attachment'); + assert.strictEqual( + attachmentEl.dataset.attachmentLocalId, + this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }).localId, + "attachment component should be linked to attachment store model" + ); + assert.strictEqual( + attachmentEl.title, + "test.txt", + "attachment should have filename as title attribute" + ); + assert.strictEqual( + attachmentEl.querySelectorAll(`:scope .o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + const attachmentImage = document.querySelector(`.o_Attachment_image`); + assert.ok( + attachmentImage.classList.contains('o_image'), + "attachment should have o_image classname (required for mimetype.scss style)" + ); + assert.strictEqual( + attachmentImage.dataset.mimetype, + 'text/plain', + "attachment should have data-mimetype set (required for mimetype.scss style)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside part" + ); +}); + +QUnit.test('simplest layout + deletable', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/750')) { + assert.ok( + route.includes('/200x200'), + "should fetch image with 200x200 pixels ratio"); + assert.step('fetch_image'); + } + return this._super(...arguments); + }, + }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: false, + isEditable: true, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 1, + "attachment should have only one aside item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length, + 1, + "attachment should have a delete button" + ); +}); + +QUnit.test('simplest layout + downloadable', async function (assert) { + assert.expect(6); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: true, + isEditable: false, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 1, + "attachment should have only one aside item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemDownload`).length, + 1, + "attachment should have a download button" + ); +}); + +QUnit.test('simplest layout + deletable + downloadable', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: true, + isEditable: true, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.ok( + document.querySelector(`.o_Attachment_aside`).classList.contains('o-has-multiple-action'), + "attachment aside should contain multiple actions" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 2, + "attachment should have only two aside items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemDownload`).length, + 1, + "attachment should have a download button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length, + 1, + "attachment should have a delete button" + ); +}); + +QUnit.test('layout with card details', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside part" + ); +}); + +QUnit.test('layout with card details and filename', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should not have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 0, + "attachment should have its extension shown" + ); +}); + +QUnit.test('layout with card details and extension', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 0, + "attachment should not have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); +}); + +QUnit.test('layout with card details and filename and extension', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); +}); + +QUnit.test('simplest layout with hover details and filename and extension', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'hover', + isDownloadable: true, + isEditable: true, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Attachment_details:not(.o_Attachment_imageOverlayDetails) + `).length, + 0, + "attachment should not have a details part directly" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length, + 1, + "attachment should have a details part in the overlay" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlay`).length, + 1, + "attachment should have an image overlay part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_actions`).length, + 1, + "attachment should have an actions part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside element" + ); +}); + +QUnit.test('auto layout with image', async function (assert) { + assert.expect(7); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.png", + id: 750, + mimetype: 'image/png', + name: "test.png", + }); + + await this.createAttachmentComponent(attachment, { + detailsMode: 'auto', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Attachment_details:not(.o_Attachment_imageOverlayDetails) + `).length, + 0, + "attachment should not have a details part directly" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length, + 1, + "attachment should have a details part in the overlay" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlay`).length, + 1, + "attachment should have an image overlay part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside element" + ); +}); + +QUnit.test('view attachment', async function (assert) { + assert.expect(3); + + await this.start({ + hasDialog: true, + }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.png", + id: 750, + mimetype: 'image/png', + name: "test.png", + }); + + await this.createAttachmentComponent(attachment, { + detailsMode: 'hover', + isDownloadable: false, + isEditable: false, + }); + assert.containsOnce( + document.body, + '.o_Attachment_image', + "attachment should have an image part" + ); + await afterNextRender(() => document.querySelector('.o_Attachment_image').click()); + assert.containsOnce( + document.body, + '.o_Dialog', + 'a dialog should have been opened once attachment image is clicked', + ); + assert.containsOnce( + document.body, + '.o_AttachmentViewer', + 'an attachment viewer should have been opened once attachment image is clicked', + ); +}); + +QUnit.test('close attachment viewer', async function (assert) { + assert.expect(3); + + await this.start({ hasDialog: true }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.png", + id: 750, + mimetype: 'image/png', + name: "test.png", + }); + + await this.createAttachmentComponent(attachment, { + detailsMode: 'hover', + isDownloadable: false, + isEditable: false, + }); + assert.containsOnce( + document.body, + '.o_Attachment_image', + "attachment should have an image part" + ); + + await afterNextRender(() => document.querySelector('.o_Attachment_image').click()); + assert.containsOnce( + document.body, + '.o_AttachmentViewer', + "an attachment viewer should have been opened once attachment image is clicked", + ); + + await afterNextRender(() => + document.querySelector('.o_AttachmentViewer_headerItemButtonClose').click() + ); + assert.containsNone( + document.body, + '.o_Dialog', + "attachment viewer should be closed after clicking on close button" + ); +}); + +QUnit.test('clicking on the delete attachment button multiple times should do the rpc only once', async function (assert) { + assert.expect(2); + await this.start({ + async mockRPC(route, args) { + if (args.method === "unlink" && args.model === "ir.attachment") { + assert.step('attachment_unlink'); + return; + } + return this._super(...arguments); + }, + }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'hover', + }); + await afterNextRender(() => { + document.querySelector('.o_Attachment_actionUnlink').click(); + }); + + await afterNextRender(() => { + document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click(); + document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click(); + document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click(); + }); + assert.verifySteps( + ['attachment_unlink'], + "The unlink method must be called once" + ); +}); + +QUnit.test('[technical] does not crash when the viewer is closed before image load', async function (assert) { + /** + * When images are displayed using `src` attribute for the 1st time, it fetches the resource. + * In this case, images are actually displayed (fully fetched and rendered on screen) when + * `<image>` intercepts `load` event. + * + * Current code needs to be aware of load state of image, to display spinner when loading + * and actual image when loaded. This test asserts no crash from mishandling image becoming + * loaded from being viewed for 1st time, but viewer being closed while image is loading. + */ + assert.expect(1); + + await this.start({ hasDialog: true }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.png", + id: 750, + mimetype: 'image/png', + name: "test.png", + }); + await this.createAttachmentComponent(attachment); + await afterNextRender(() => document.querySelector('.o_Attachment_image').click()); + const imageEl = document.querySelector('.o_AttachmentViewer_viewImage'); + await afterNextRender(() => + document.querySelector('.o_AttachmentViewer_headerItemButtonClose').click() + ); + // Simulate image becoming loaded. + let successfulLoad; + try { + imageEl.dispatchEvent(new Event('load', { bubbles: true })); + successfulLoad = true; + } catch (err) { + successfulLoad = false; + } finally { + assert.ok(successfulLoad, 'should not crash when the image is loaded'); + } +}); + +QUnit.test('plain text file is viewable', async function (assert) { + assert.expect(1); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + }); + assert.hasClass( + document.querySelector('.o_Attachment'), + 'o-viewable', + "should be viewable", + ); +}); + +QUnit.test('HTML file is viewable', async function (assert) { + assert.expect(1); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.html", + id: 750, + mimetype: 'text/html', + name: "test.html", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + }); + assert.hasClass( + document.querySelector('.o_Attachment'), + 'o-viewable', + "should be viewable", + ); +}); + +QUnit.test('ODT file is not viewable', async function (assert) { + assert.expect(1); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.odt", + id: 750, + mimetype: 'application/vnd.oasis.opendocument.text', + name: "test.odt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + }); + assert.doesNotHaveClass( + document.querySelector('.o_Attachment'), + 'o-viewable', + "should not be viewable", + ); +}); + +QUnit.test('DOCX file is not viewable', async function (assert) { + assert.expect(1); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.docx", + id: 750, + mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + name: "test.docx", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + }); + assert.doesNotHaveClass( + document.querySelector('.o_Attachment'), + 'o-viewable', + "should not be viewable", + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.js b/addons/mail/static/src/components/attachment_box/attachment_box.js new file mode 100644 index 00000000..5bfe7c06 --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.js @@ -0,0 +1,124 @@ +odoo.define('mail/static/src/components/attachment_box/attachment_box.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), +}; +const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js'); +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class AttachmentBox extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.isDropZoneVisible = useDragVisibleDropZone(); + useShouldUpdateBasedOnProps(); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + thread, + threadAllAttachments: thread ? thread.allAttachments : [], + threadId: thread && thread.id, + threadModel: thread && thread.model, + }; + }, { + compareDepth: { + threadAllAttachments: 1, + }, + }); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get an object which is passed to FileUploader component to be used when + * creating attachment. + * + * @returns {Object} + */ + get newAttachmentExtraData() { + return { + originThread: [['link', this.thread]], + }; + } + + /** + * @returns {mail.thread|undefined} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onAttachmentCreated(ev) { + // FIXME Could be changed by spying attachments count (task-2252858) + this.trigger('o-attachments-changed'); + } + + /** + * @private + * @param {Event} ev + */ + _onAttachmentRemoved(ev) { + // FIXME Could be changed by spying attachments count (task-2252858) + this.trigger('o-attachments-changed'); + } + + /** + * @private + * @param {Event} ev + */ + _onClickAdd(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this._fileUploaderRef.comp.openBrowserFileUploader(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {FileList} ev.detail.files + */ + async _onDropZoneFilesDropped(ev) { + ev.stopPropagation(); + await this._fileUploaderRef.comp.uploadFiles(ev.detail.files); + this.isDropZoneVisible.value = false; + } + +} + +Object.assign(AttachmentBox, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.AttachmentBox', +}); + +return AttachmentBox; + +}); diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.scss b/addons/mail/static/src/components/attachment_box/attachment_box.scss new file mode 100644 index 00000000..d51cca9c --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.scss @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentBox { + position: relative; +} + +.o_AttachmentBox_buttonAdd { + align-self: center; +} + +.o_AttachmentBox_content { + display: flex; + flex-direction: column; +} + +.o_AttachmentBox_dashedLine { + flex-grow: 1; +} + +.o_AttachmentBox_fileInput { + display: none; +} + +.o_AttachmentBox_title { + display: flex; + align-items: center; +} + +.o_AttachmentBox_titleText { + padding: map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_AttachmentBox_dashedLine { + border-style: dashed; + border-color: gray('300'); +} + +.o_AttachmentBox_title { + font-weight: bold; +} diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.xml b/addons/mail/static/src/components/attachment_box/attachment_box.xml new file mode 100644 index 00000000..9cd3e713 --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentBox" owl="1"> + <div class="o_AttachmentBox"> + <div class="o_AttachmentBox_title"> + <hr class="o_AttachmentBox_dashedLine"/> + <span class="o_AttachmentBox_titleText"> + Attachments + </span> + <hr class="o_AttachmentBox_dashedLine"/> + </div> + <div class="o_AttachmentBox_content"> + <t t-if="isDropZoneVisible.value"> + <DropZone + class="o_AttachmentBox_dropZone" + t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped" + t-ref="dropzone" + /> + </t> + <t t-if="thread and thread.allAttachments.length > 0"> + <AttachmentList + class="o_attachmentBox_attachmentList" + areAttachmentsDownloadable="true" + attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="'hover'" + attachmentsImageSize="'small'" + showAttachmentsFilenames="true" + t-on-o-attachment-removed="_onAttachmentRemoved" + /> + </t> + <button class="o_AttachmentBox_buttonAdd btn btn-link" type="button" t-on-click="_onClickAdd"> + <i class="fa fa-plus-square"/> + Add attachments + </button> + </div> + <t t-if="thread"> + <FileUploader + attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)" + newAttachmentExtraData="newAttachmentExtraData" + uploadModel="thread.model" + uploadId="thread.id" + t-on-o-attachment-created="_onAttachmentCreated" + t-ref="fileUploader" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment_box/attachment_box_tests.js b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js new file mode 100644 index 00000000..142eb804 --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js @@ -0,0 +1,337 @@ +odoo.define('mail/static/src/components/attachment_box/attachment_box_tests.js', function (require) { +"use strict"; + +const components = { + AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + dragenterFiles, + dropFiles, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const { file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('attachment_box', {}, function () { +QUnit.module('attachment_box_tests.js', { + beforeEach() { + beforeEach(this); + + this.createAttachmentBoxComponent = async (thread, otherProps) => { + const props = Object.assign({ threadLocalId: thread.localId }, otherProps); + await createRootComponent(this, components.AttachmentBox, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('base empty rendering', async function (assert) { + assert.expect(4); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createAttachmentBoxComponent(thread); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox`).length, + 1, + "should have an attachment box" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length, + 1, + "should have a button add" + ); + assert.strictEqual( + document.querySelectorAll(`.o_FileUploader_input`).length, + 1, + "should have a file input" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 0, + "should not have any attachment" + ); +}); + +QUnit.test('base non-empty rendering', async function (assert) { + assert.expect(6); + + this.data['ir.attachment'].records.push( + { + mimetype: 'text/plain', + name: 'Blah.txt', + res_id: 100, + res_model: 'res.partner', + }, + { + mimetype: 'text/plain', + name: 'Blu.txt', + res_id: 100, + res_model: 'res.partner', + } + ); + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + assert.step('ir.attachment/search_read'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await thread.fetchAttachments(); + await this.createAttachmentBoxComponent(thread); + assert.verifySteps( + ['ir.attachment/search_read'], + "should have fetched attachments" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox`).length, + 1, + "should have an attachment box" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length, + 1, + "should have a button add" + ); + assert.strictEqual( + document.querySelectorAll(`.o_FileUploader_input`).length, + 1, + "should have a file input" + ); + assert.strictEqual( + document.querySelectorAll(`.o_attachmentBox_attachmentList`).length, + 1, + "should have an attachment list" + ); +}); + +QUnit.test('attachment box: drop attachments', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await thread.fetchAttachments(); + await this.createAttachmentBoxComponent(thread); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }), + ]; + assert.strictEqual( + document.querySelectorAll('.o_AttachmentBox').length, + 1, + "should have an attachment box" + ); + + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_AttachmentBox')) + ); + assert.ok( + document.querySelector('.o_AttachmentBox_dropZone'), + "should have a drop zone" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 0, + "should have no attachment before files are dropped" + ); + + await afterNextRender(() => + dropFiles( + document.querySelector('.o_AttachmentBox_dropZone'), + files + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 1, + "should have 1 attachment in the box after files dropped" + ); + + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_AttachmentBox')) + ); + const file1 = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text2.txt', + }); + const file2 = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text3.txt', + }); + await afterNextRender(() => + dropFiles( + document.querySelector('.o_AttachmentBox_dropZone'), + [file1, file2] + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 3, + "should have 3 attachments in the box after files dropped" + ); +}); + +QUnit.test('view attachments', async function (assert) { + assert.expect(7); + + await this.start({ + hasDialog: true, + }); + const thread = this.env.models['mail.thread'].create({ + attachments: [ + ['insert', { + id: 143, + mimetype: 'text/plain', + name: 'Blah.txt' + }], + ['insert', { + id: 144, + mimetype: 'text/plain', + name: 'Blu.txt' + }] + ], + id: 100, + model: 'res.partner', + }); + const firstAttachment = this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }); + await this.createAttachmentBoxComponent(thread); + + await afterNextRender(() => + document.querySelector(` + .o_Attachment[data-attachment-local-id="${firstAttachment.localId}"] + .o_Attachment_image + `).click() + ); + assert.containsOnce( + document.body, + '.o_Dialog', + "a dialog should have been opened once attachment image is clicked", + ); + assert.containsOnce( + document.body, + '.o_AttachmentViewer', + "an attachment viewer should have been opened once attachment image is clicked", + ); + assert.strictEqual( + document.querySelector('.o_AttachmentViewer_name').textContent, + 'Blah.txt', + "attachment viewer iframe should point to clicked attachment", + ); + assert.containsOnce( + document.body, + '.o_AttachmentViewer_buttonNavigationNext', + "attachment viewer should allow to see next attachment", + ); + + await afterNextRender(() => + document.querySelector('.o_AttachmentViewer_buttonNavigationNext').click() + ); + assert.strictEqual( + document.querySelector('.o_AttachmentViewer_name').textContent, + 'Blu.txt', + "attachment viewer iframe should point to next attachment of attachment box", + ); + assert.containsOnce( + document.body, + '.o_AttachmentViewer_buttonNavigationNext', + "attachment viewer should allow to see next attachment", + ); + + await afterNextRender(() => + document.querySelector('.o_AttachmentViewer_buttonNavigationNext').click() + ); + assert.strictEqual( + document.querySelector('.o_AttachmentViewer_name').textContent, + 'Blah.txt', + "attachment viewer iframe should point anew to first attachment", + ); +}); + +QUnit.test('remove attachment should ask for confirmation', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + attachments: [ + ['insert', { + id: 143, + mimetype: 'text/plain', + name: 'Blah.txt' + }], + ], + id: 100, + model: 'res.partner', + }); + await this.createAttachmentBoxComponent(thread); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have an attachment", + ); + assert.containsOnce( + document.body, + '.o_Attachment_asideItemUnlink', + "attachment should have a delete button" + ); + + await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click()); + assert.containsOnce( + document.body, + '.o_AttachmentDeleteConfirmDialog', + "A confirmation dialog should have been opened" + ); + assert.strictEqual( + document.querySelector('.o_AttachmentDeleteConfirmDialog_mainText').textContent, + `Do you really want to delete "Blah.txt"?`, + "Confirmation dialog should contain the attachment delete confirmation text" + ); + + // Confirm the deletion + await afterNextRender(() => document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()); + assert.containsNone( + document.body, + '.o_Attachment', + "should no longer have an attachment", + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js b/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js new file mode 100644 index 00000000..ab7e155a --- /dev/null +++ b/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js @@ -0,0 +1,92 @@ +odoo.define('mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + Dialog: require('web.OwlDialog'), +}; + +const { Component } = owl; +const { useRef } = owl.hooks; + +class AttachmentDeleteConfirmDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId); + return { + attachment: attachment ? attachment.__state : undefined, + }; + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.attachment} + */ + get attachment() { + return this.env.models['mail.attachment'].get(this.props.attachmentLocalId); + } + + /** + * @returns {string} + */ + getBody() { + return _.str.sprintf( + this.env._t(`Do you really want to delete "%s"?`), + owl.utils.escape(this.attachment.displayName) + ); + } + + /** + * @returns {string} + */ + getTitle() { + return this.env._t("Confirmation"); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + + /** + * @private + */ + _onClickOk() { + this._dialogRef.comp._close(); + this.attachment.remove(); + this.trigger('o-attachment-removed', { attachmentLocalId: this.props.attachmentLocalId }); + } + +} + +Object.assign(AttachmentDeleteConfirmDialog, { + components, + props: { + attachmentLocalId: String, + }, + template: 'mail.AttachmentDeleteConfirmDialog', +}); + +return AttachmentDeleteConfirmDialog; + +}); diff --git a/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.xml b/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.xml new file mode 100644 index 00000000..6b466a9b --- /dev/null +++ b/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.AttachmentDeleteConfirmDialog" owl="1"> + <Dialog contentClass="'o_AttachmentDeleteConfirmDialog'" title="getTitle()" size="'medium'" t-ref="dialog"> + <p class="o_AttachmentDeleteConfirmDialog_mainText" t-esc="getBody()"/> + <t t-set-slot="buttons"> + <button class="o_AttachmentDeleteConfirmDialog_confirmButton btn btn-primary" t-on-click="_onClickOk">Ok</button> + <button class="o_AttachmentDeleteConfirmDialog_cancelButton btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </t> +</templates> diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.js b/addons/mail/static/src/components/attachment_list/attachment_list.js new file mode 100644 index 00000000..d8658ac8 --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.js @@ -0,0 +1,119 @@ +odoo.define('mail/static/src/components/attachment_list/attachment_list.js', function (require) { +'use strict'; + +const components = { + Attachment: require('mail/static/src/components/attachment/attachment.js'), +}; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class AttachmentList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + attachmentLocalIds: 1, + }, + }); + useStore(props => { + const attachments = this.env.models['mail.attachment'].all().filter(attachment => + props.attachmentLocalIds.includes(attachment.localId) + ); + return { + attachments: attachments + ? attachments.map(attachment => attachment.__state) + : undefined, + }; + }, { + compareDepth: { + attachments: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.attachment[]} + */ + get attachments() { + return this.env.models['mail.attachment'].all().filter(attachment => + this.props.attachmentLocalIds.includes(attachment.localId) + ); + } + + /** + * @returns {mail.attachment[]} + */ + get imageAttachments() { + return this.attachments.filter(attachment => attachment.fileType === 'image'); + } + + /** + * @returns {mail.attachment[]} + */ + get nonImageAttachments() { + return this.attachments.filter(attachment => attachment.fileType !== 'image'); + } + + /** + * @returns {mail.attachment[]} + */ + get viewableAttachments() { + return this.attachments.filter(attachment => attachment.isViewable); + } + +} + +Object.assign(AttachmentList, { + components, + defaultProps: { + attachmentLocalIds: [], + }, + props: { + areAttachmentsDownloadable: { + type: Boolean, + optional: true, + }, + areAttachmentsEditable: { + type: Boolean, + optional: true, + }, + attachmentLocalIds: { + type: Array, + element: String, + }, + attachmentsDetailsMode: { + type: String, + optional: true, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + attachmentsImageSize: { + type: String, + optional: true, + validate: prop => ['small', 'medium', 'large'].includes(prop), + }, + showAttachmentsExtensions: { + type: Boolean, + optional: true, + }, + showAttachmentsFilenames: { + type: Boolean, + optional: true, + }, + }, + template: 'mail.AttachmentList', +}); + +return AttachmentList; + +}); diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.scss b/addons/mail/static/src/components/attachment_list/attachment_list.scss new file mode 100644 index 00000000..dfe281ae --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.scss @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentList { + display: flex; + flex-flow: column; + justify-content: flex-start; +} + +/* Avoid overflow of long attachment text */ +.o_AttachmentList_attachment { + margin-bottom: map-get($spacers, 1); + margin-top: map-get($spacers, 1); + margin-inline-end: map-get($spacers, 1); + margin-inline-start: map-get($spacers, 0); + max-width: 100%; +} + +.o_AttachmentList_partialList { + display: flex; + flex: 1; + flex-flow: wrap; +} + +.o_AttachmentList_partialListNonImages { + margin: map-get($spacers, 1); + justify-content: flex-start; +} diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.xml b/addons/mail/static/src/components/attachment_list/attachment_list.xml new file mode 100644 index 00000000..39499285 --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentList" owl="1"> + <div class="o_AttachmentList"> + <div class="o_AttachmentList_partialList o_AttachmentList_partialListImages"> + <t t-foreach="imageAttachments" t-as="attachment" t-key="attachment.localId"> + <Attachment + class="o_AttachmentList_attachment o_AttachmentList_imageAttachment" + attachmentLocalId="attachment.localId" + attachmentLocalIds="viewableAttachments.map(attachment => attachment.localId)" + detailsMode="props.attachmentsDetailsMode" + imageSize="props.attachmentsImageSize" + isDownloadable="props.areAttachmentsDownloadable" + isEditable="props.areAttachmentsEditable" + showExtension="props.showAttachmentsExtensions" + showFilename="props.showAttachmentsFilenames" + /> + </t> + </div> + <div class="o_AttachmentList_partialList o_AttachmentList_partialListNonImages"> + <t t-foreach="nonImageAttachments" t-as="attachment" t-key="attachment.localId"> + <Attachment + class="o_AttachmentList_attachment o_AttachmentList_nonImageAttachment" + attachmentLocalId="attachment.localId" + attachmentLocalIds="viewableAttachments.map(attachment => attachment.localId)" + detailsMode="'card'" + imageSize="props.attachmentsImageSize" + isDownloadable="props.areAttachmentsDownloadable" + isEditable="props.areAttachmentsEditable" + showExtension="props.showAttachmentsExtensions" + showFilename="props.showAttachmentsFilenames" + /> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js new file mode 100644 index 00000000..30755fd9 --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js @@ -0,0 +1,598 @@ +odoo.define('mail/static/src/components/attachment_viewer/attachment_viewer.js', function (require) { +'use strict'; + +const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js'); +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component, QWeb } = owl; +const { useRef } = owl.hooks; + +const MIN_SCALE = 0.5; +const SCROLL_ZOOM_STEP = 0.1; +const ZOOM_STEP = 0.5; + +class AttachmentViewer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.MIN_SCALE = MIN_SCALE; + useShouldUpdateBasedOnProps(); + useStore(props => { + const attachmentViewer = this.env.models['mail.attachment_viewer'].get(props.localId); + return { + attachment: attachmentViewer && attachmentViewer.attachment + ? attachmentViewer.attachment.__state + : undefined, + attachments: attachmentViewer + ? attachmentViewer.attachments.map(attachment => attachment.__state) + : [], + attachmentViewer: attachmentViewer ? attachmentViewer.__state : undefined, + }; + }); + /** + * Used to ensure that the ref is always up to date, which seems to be needed if the element + * has a t-key, which was added to force the rendering of a new element when the src of the image changes. + * This was made to remove the display of the previous image as soon as the src changes. + */ + this._getRefs = useRefs(); + /** + * Determine whether the user is currently dragging the image. + * This is useful to determine whether a click outside of the image + * should close the attachment viewer or not. + */ + this._isDragging = false; + /** + * Reference of the zoomer node. Useful to apply translate + * transformation on image visualisation. + */ + this._zoomerRef = useRef('zoomer'); + /** + * Tracked translate transformations on image visualisation. This is + * not observed with `useStore` because they are used to compute zoomer + * style, and this is changed directly on zoomer for performance + * reasons (overhead of making vdom is too significant for each mouse + * position changes while dragging) + */ + this._translate = { x: 0, y: 0, dx: 0, dy: 0 }; + this._onClickGlobal = this._onClickGlobal.bind(this); + } + + mounted() { + this.el.focus(); + this._handleImageLoad(); + document.addEventListener('click', this._onClickGlobal); + } + + /** + * When a new image is displayed, show a spinner until it is loaded. + */ + patched() { + this._handleImageLoad(); + } + + willUnmount() { + document.removeEventListener('click', this._onClickGlobal); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.attachment_viewer} + */ + get attachmentViewer() { + return this.env.models['mail.attachment_viewer'].get(this.props.localId); + } + + /** + * Compute the style of the image (scale + rotation). + * + * @returns {string} + */ + get imageStyle() { + const attachmentViewer = this.attachmentViewer; + let style = `transform: ` + + `scale3d(${attachmentViewer.scale}, ${attachmentViewer.scale}, 1) ` + + `rotate(${attachmentViewer.angle}deg);`; + + if (attachmentViewer.angle % 180 !== 0) { + style += `` + + `max-height: ${window.innerWidth}px; ` + + `max-width: ${window.innerHeight}px;`; + } else { + style += `` + + `max-height: 100%; ` + + `max-width: 100%;`; + } + return style; + } + + /** + * Mandatory method for dialog components. + * Prevent closing the dialog when clicking on the mask when the user is + * currently dragging the image. + * + * @returns {boolean} + */ + isCloseable() { + return !this._isDragging; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Close the dialog with this attachment viewer. + * + * @private + */ + _close() { + this.attachmentViewer.close(); + } + + /** + * Download the attachment. + * + * @private + */ + _download() { + const id = this.attachmentViewer.attachment.id; + this.env.services.navigate(`/web/content/ir.attachment/${id}/datas`, { download: true }); + } + + /** + * Determine whether the current image is rendered for the 1st time, and if + * that's the case, display a spinner until loaded. + * + * @private + */ + _handleImageLoad() { + if (!this.attachmentViewer || !this.attachmentViewer.attachment) { + return; + } + const refs = this._getRefs(); + const image = refs[`image_${this.attachmentViewer.attachment.id}`]; + if ( + this.attachmentViewer.attachment.fileType === 'image' && + (!image || !image.complete) + ) { + this.attachmentViewer.update({ isImageLoading: true }); + } + } + + /** + * Display the previous attachment in the list of attachments. + * + * @private + */ + _next() { + const attachmentViewer = this.attachmentViewer; + const index = attachmentViewer.attachments.findIndex(attachment => + attachment === attachmentViewer.attachment + ); + const nextIndex = (index + 1) % attachmentViewer.attachments.length; + attachmentViewer.update({ + attachment: [['link', attachmentViewer.attachments[nextIndex]]], + }); + } + + /** + * Display the previous attachment in the list of attachments. + * + * @private + */ + _previous() { + const attachmentViewer = this.attachmentViewer; + const index = attachmentViewer.attachments.findIndex(attachment => + attachment === attachmentViewer.attachment + ); + const nextIndex = index === 0 + ? attachmentViewer.attachments.length - 1 + : index - 1; + attachmentViewer.update({ + attachment: [['link', attachmentViewer.attachments[nextIndex]]], + }); + } + + /** + * Prompt the browser print of this attachment. + * + * @private + */ + _print() { + const printWindow = window.open('about:blank', '_new'); + printWindow.document.open(); + printWindow.document.write(` + <html> + <head> + <script> + function onloadImage() { + setTimeout('printImage()', 10); + } + function printImage() { + window.print(); + window.close(); + } + </script> + </head> + <body onload='onloadImage()'> + <img src="${this.attachmentViewer.attachment.defaultSource}" alt=""/> + </body> + </html>`); + printWindow.document.close(); + } + + /** + * Rotate the image by 90 degrees to the right. + * + * @private + */ + _rotate() { + this.attachmentViewer.update({ angle: this.attachmentViewer.angle + 90 }); + } + + /** + * Stop dragging interaction of the user. + * + * @private + */ + _stopDragging() { + this._isDragging = false; + this._translate.x += this._translate.dx; + this._translate.y += this._translate.dy; + this._translate.dx = 0; + this._translate.dy = 0; + this._updateZoomerStyle(); + } + + /** + * Update the style of the zoomer based on translate transformation. Changes + * are directly applied on zoomer, instead of triggering re-render and + * defining them in the template, for performance reasons. + * + * @private + * @returns {string} + */ + _updateZoomerStyle() { + const attachmentViewer = this.attachmentViewer; + const refs = this._getRefs(); + const image = refs[`image_${this.attachmentViewer.attachment.id}`]; + const tx = image.offsetWidth * attachmentViewer.scale > this._zoomerRef.el.offsetWidth + ? this._translate.x + this._translate.dx + : 0; + const ty = image.offsetHeight * attachmentViewer.scale > this._zoomerRef.el.offsetHeight + ? this._translate.y + this._translate.dy + : 0; + if (tx === 0) { + this._translate.x = 0; + } + if (ty === 0) { + this._translate.y = 0; + } + this._zoomerRef.el.style = `transform: ` + + `translate(${tx}px, ${ty}px)`; + } + + /** + * Zoom in the image. + * + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.scroll=false] + */ + _zoomIn({ scroll = false } = {}) { + this.attachmentViewer.update({ + scale: this.attachmentViewer.scale + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP), + }); + this._updateZoomerStyle(); + } + + /** + * Zoom out the image. + * + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.scroll=false] + */ + _zoomOut({ scroll = false } = {}) { + if (this.attachmentViewer.scale === MIN_SCALE) { + return; + } + const unflooredAdaptedScale = ( + this.attachmentViewer.scale - + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP) + ); + this.attachmentViewer.update({ + scale: Math.max(MIN_SCALE, unflooredAdaptedScale), + }); + this._updateZoomerStyle(); + } + + /** + * Reset the zoom scale of the image. + * + * @private + */ + _zoomReset() { + this.attachmentViewer.update({ scale: 1 }); + this._updateZoomerStyle(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on mask of attachment viewer. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (this._isDragging) { + return; + } + // TODO: clicking on the background should probably be handled by the dialog? + // task-2092965 + this._close(); + } + + /** + * Called when clicking on cross icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + this._close(); + } + + /** + * Called when clicking on download icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDownload(ev) { + ev.stopPropagation(); + this._download(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickGlobal(ev) { + if (!this._isDragging) { + return; + } + ev.stopPropagation(); + this._stopDragging(); + } + + /** + * Called when clicking on the header. Stop propagation of event to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickHeader(ev) { + ev.stopPropagation(); + } + + /** + * Called when clicking on image. Stop propagation of event to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickImage(ev) { + if (this._isDragging) { + return; + } + ev.stopPropagation(); + } + + /** + * Called when clicking on next icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickNext(ev) { + ev.stopPropagation(); + this._next(); + } + + /** + * Called when clicking on previous icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickPrevious(ev) { + ev.stopPropagation(); + this._previous(); + } + + /** + * Called when clicking on print icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickPrint(ev) { + ev.stopPropagation(); + this._print(); + } + + /** + * Called when clicking on rotate icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickRotate(ev) { + ev.stopPropagation(); + this._rotate(); + } + + /** + * Called when clicking on embed video player. Stop propagation to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickVideo(ev) { + ev.stopPropagation(); + } + + /** + * Called when clicking on zoom in icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomIn(ev) { + ev.stopPropagation(); + this._zoomIn(); + } + + /** + * Called when clicking on zoom out icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomOut(ev) { + ev.stopPropagation(); + this._zoomOut(); + } + + /** + * Called when clicking on reset zoom icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomReset(ev) { + ev.stopPropagation(); + this._zoomReset(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + switch (ev.key) { + case 'ArrowRight': + this._next(); + break; + case 'ArrowLeft': + this._previous(); + break; + case 'Escape': + this._close(); + break; + case 'q': + this._close(); + break; + case 'r': + this._rotate(); + break; + case '+': + this._zoomIn(); + break; + case '-': + this._zoomOut(); + break; + case '0': + this._zoomReset(); + break; + default: + return; + } + ev.stopPropagation(); + } + + /** + * Called when new image has been loaded + * + * @private + * @param {Event} ev + */ + _onLoadImage(ev) { + ev.stopPropagation(); + this.attachmentViewer.update({ isImageLoading: false }); + } + + /** + * @private + * @param {DragEvent} ev + */ + _onMousedownImage(ev) { + if (this._isDragging) { + return; + } + if (ev.button !== 0) { + return; + } + ev.stopPropagation(); + this._isDragging = true; + this._dragstartX = ev.clientX; + this._dragstartY = ev.clientY; + } + + /** + * @private + * @param {DragEvent} + */ + _onMousemoveView(ev) { + if (!this._isDragging) { + return; + } + this._translate.dx = ev.clientX - this._dragstartX; + this._translate.dy = ev.clientY - this._dragstartY; + this._updateZoomerStyle(); + } + + /** + * @private + * @param {Event} ev + */ + _onWheelImage(ev) { + ev.stopPropagation(); + if (!this.el) { + return; + } + if (ev.deltaY > 0) { + this._zoomOut({ scroll: true }); + } else { + this._zoomIn({ scroll: true }); + } + } + +} + +Object.assign(AttachmentViewer, { + props: { + localId: String, + }, + template: 'mail.AttachmentViewer', +}); + +QWeb.registerComponent('AttachmentViewer', AttachmentViewer); + +return AttachmentViewer; + +}); diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss new file mode 100644 index 00000000..54f00c1a --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss @@ -0,0 +1,198 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentViewer { + display: flex; + width: 100%; + height: 100%; + flex-flow: column; + align-items: center; + z-index: -1; +} + +.o_AttachmentViewer_buttonNavigation { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + top: 50%; + transform: translateY(-50%); +} + +.o_AttachmentViewer_buttonNavigationNext { + right: 15px; + + > .fa { + margin: 1px 0 0 1px; // not correctly centered for some reasons + } +} + +.o_AttachmentViewer_buttonNavigationPrevious { + left: 15px; + + > .fa { + margin: 1px 1px 0 0; // not correctly centered for some reasons + } +} + +.o_AttachmentViewer_header { + display: flex; + height: $o-navbar-height; + align-items: center; + padding: 0 15px; + width: 100%; +} + +.o_AttachmentViewer_headerItem { + margin: 0 5px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } +} + +.o_AttachmentViewer_loading { + position: absolute; +} + +.o_AttachmentViewer_main { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: -1; + padding: 45px 0; + + &.o_with_img { + overflow: hidden; + } +} + +.o_AttachmentViewer_toolbar { + position: absolute; + bottom: 45px; + transform: translateY(100%); + display: flex; +} + +.o_AttachmentViewer_toolbarButton { + padding: 8px; +} + +.o_AttachmentViewer_viewImage { + max-height: 100%; + max-width: 100%; +} + +.o_AttachmentViewer_viewIframe { + width: 90%; + height: 100%; +} + +.o_AttachmentViewer_viewVideo { + width: 75%; + height: 75%; +} + +.o_AttachmentViewer_zoomer { + position: absolute; + padding: 45px 0; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_AttachmentViewer { + outline: none; +} + +.o_AttachmentViewer_buttonNavigation { + color: gray('400'); + background-color: lighten(black, 15%); + border-radius: 100%; + cursor: pointer; + + &:hover { + color: lighten(gray('400'), 15%); + background-color: black; + } +} + +.o_AttachmentViewer_header { + background-color: rgba(0, 0, 0, 0.7); + color: gray('400'); +} + +.o_AttachmentViewer_headerItemButton { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + color: lighten(gray('400'), 15%); + } +} + +.o_AttachmentViewer_headerItemButtonClose { + cursor: pointer; + font-size: 1.3rem; +} + +.o_AttachmentViewer_toolbar { + cursor: pointer; +} + +.o_AttachmentViewer_toolbarButton { + background-color: lighten(black, 15%); + + &.o_disabled { + cursor: not-allowed; + filter: brightness(1.3); + } + + &:not(.o_disabled) { + color: gray('400'); + cursor: pointer; + + &:hover { + background-color: black; + color: lighten(gray('400'), 15%); + } + } +} + +.o_AttachmentViewer_view { + background-color: black; + box-shadow: 0 0 40px black; + outline: none; + border: none; + + &.o_text { + background-color: white; + } +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_AttachmentViewer_viewImage { + transition: transform 0.3s ease; +} + diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml new file mode 100644 index 00000000..8791bd09 --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentViewer" owl="1"> + <div class="o_AttachmentViewer" t-on-click="_onClick" t-on-keydown="_onKeydown" tabindex="0"> + <div class="o_AttachmentViewer_header" t-on-click="_onClickHeader"> + <t t-if="attachmentViewer.attachment.fileType"> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_icon"> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <i class="fa fa-picture-o" role="img" title="Image"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'"> + <i class="fa fa-file-text" role="img" title="PDF file"/> + </t> + <t t-if="attachmentViewer.attachment.isTextFile"> + <i class="fa fa-file-text" role="img" title="Text file"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'video'"> + <i class="fa fa-video-camera" role="img" title="Video"/> + </t> + </div> + </t> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_name"> + <t t-esc="attachmentViewer.attachment.displayName"/> + </div> + <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton" t-on-click="_onClickDownload" role="button" title="Download"> + <i class="fa fa-download fa-fw" role="img"/> + </div> + <div class="o-autogrow"/> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton o_AttachmentViewer_headerItemButtonClose" t-on-click="_onClickClose" role="button" title="Close (Esc)" aria-label="Close"> + <i class="fa fa-fw fa-times" role="img"/> + </div> + </div> + <div class="o_AttachmentViewer_main" t-att-class="{ o_with_img: attachmentViewer.attachment.fileType === 'image' }" t-on-mousemove="_onMousemoveView"> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <div class="o_AttachmentViewer_zoomer" t-ref="zoomer"> + <t t-if="attachmentViewer.isImageLoading"> + <div class="o_AttachmentViewer_loading"> + <i class="fa fa-3x fa-circle-o-notch fa-fw fa-spin" role="img" title="Loading"/> + </div> + </t> + <img class="o_AttachmentViewer_view o_AttachmentViewer_viewImage" t-on-click="_onClickImage" t-on-mousedown="_onMousedownImage" t-on-wheel="_onWheelImage" t-on-load="_onLoadImage" t-att-src="attachmentViewer.attachment.defaultSource" t-att-style="imageStyle" draggable="false" alt="Viewer" t-key="'image_' + attachmentViewer.attachment.id" t-ref="image_{{ attachmentViewer.attachment.id }}"/> + </div> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'"> + <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_viewPdf" t-att-src="attachmentViewer.attachment.defaultSource"/> + </t> + <t t-if="attachmentViewer.attachment.isTextFile"> + <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_text" t-att-src="attachmentViewer.attachment.defaultSource"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'youtu'"> + <iframe allow="autoplay; encrypted-media" class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_youtube" t-att-src="attachmentViewer.attachment.defaultSource" height="315" width="560"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'video'"> + <video class="o_AttachmentViewer_view o_AttachmentViewer_viewVideo" t-on-click="_onClickVideo" controls="controls"> + <source t-att-data-type="attachmentViewer.attachment.mimetype" t-att-src="attachmentViewer.attachment.defaultSource"/> + </video> + </t> + </div> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <div class="o_AttachmentViewer_toolbar" role="toolbar"> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickZoomIn" title="Zoom In (+)" role="button"> + <i class="fa fa-fw fa-plus" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === 1 }" t-on-click="_onClickZoomReset" role="button" title="Reset Zoom (0)"> + <i class="fa fa-fw fa-search" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === MIN_SCALE }" t-on-click="_onClickZoomOut" title="Zoom Out (-)" role="button"> + <i class="fa fa-fw fa-minus" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickRotate" title="Rotate (r)" role="button"> + <i class="fa fa-fw fa-repeat" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickPrint" title="Print" role="button"> + <i class="fa fa-fw fa-print" role="img"/> + </div> + <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_toolbarButton" t-on-click="_onClickDownload" title="Download" role="button"> + <i class="fa fa-download fa-fw" role="img"/> + </div> + </div> + </t> + <t t-if="attachmentViewer.attachments.length > 1"> + <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationPrevious" t-on-click="_onClickPrevious" title="Previous (Left-Arrow)" role="button"> + <span class="fa fa-chevron-left" role="img"/> + </div> + <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationNext" t-on-click="_onClickNext" title="Next (Right-Arrow)" role="button"> + <span class="fa fa-chevron-right" role="img"/> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js new file mode 100644 index 00000000..c6e268e5 --- /dev/null +++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js @@ -0,0 +1,174 @@ +odoo.define('mail/static/src/components/autocomplete_input/autocomplete_input.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); + +const { Component } = owl; + +class AutocompleteInput extends Component { + + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + } + + mounted() { + if (this.props.isFocusOnMount) { + this.el.focus(); + } + + let args = { + autoFocus: true, + select: (ev, ui) => this._onAutocompleteSelect(ev, ui), + source: (req, res) => this._onAutocompleteSource(req, res), + focus: ev => this._onAutocompleteFocus(ev), + html: this.props.isHtml || false, + }; + + if (this.props.customClass) { + args.classes = { 'ui-autocomplete': this.props.customClass }; + } + + const autoCompleteElem = $(this.el).autocomplete(args); + // Resize the autocomplete dropdown options to handle the long strings + // By setting the width of dropdown based on the width of the input element. + autoCompleteElem.data("ui-autocomplete")._resizeMenu = function () { + const ul = this.menu.element; + ul.outerWidth(this.element.outerWidth()); + }; + } + + willUnmount() { + $(this.el).autocomplete('destroy'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns whether the given node is self or a children of self, including + * the suggestion menu. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + if (this.el.contains(node)) { + return true; + } + if (!this.props.customClass) { + return false; + } + const element = document.querySelector(`.${this.props.customClass}`); + if (!element) { + return false; + } + return element.contains(node); + } + + focus() { + if (!this.el) { + return; + } + this.el.focus(); + } + + focusout() { + if (!this.el) { + return; + } + this.el.blur(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {FocusEvent} ev + */ + _onAutocompleteFocus(ev) { + if (this.props.focus) { + this.props.focus(ev); + } else { + ev.preventDefault(); + } + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + */ + _onAutocompleteSelect(ev, ui) { + if (this.props.select) { + this.props.select(ev, ui); + } + } + + /** + * @private + * @param {Object} req + * @param {function} res + */ + _onAutocompleteSource(req, res) { + if (this.props.source) { + this.props.source(req, res); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onBlur(ev) { + this.trigger('o-hide'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onKeydown(ev) { + if (ev.key === 'Escape') { + this.trigger('o-hide'); + } + } + +} + +Object.assign(AutocompleteInput, { + defaultProps: { + isFocusOnMount: false, + isHtml: false, + placeholder: '', + }, + props: { + customClass: { + type: String, + optional: true, + }, + focus: { + type: Function, + optional: true, + }, + isFocusOnMount: Boolean, + isHtml: Boolean, + placeholder: String, + select: { + type: Function, + optional: true, + }, + source: { + type: Function, + optional: true, + }, + }, + template: 'mail.AutocompleteInput', +}); + +return AutocompleteInput; + +}); diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml new file mode 100644 index 00000000..ffa1bc89 --- /dev/null +++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AutocompleteInput" owl="1"> + <input class="o_AutocompleteInput" t-on-blur="_onBlur" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window/chat_window.js b/addons/mail/static/src/components/chat_window/chat_window.js new file mode 100644 index 00000000..f9271523 --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.js @@ -0,0 +1,363 @@ +odoo.define('mail/static/src/components/chat_window/chat_window.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'), + ThreadView: require('mail/static/src/components/thread_view/thread_view.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); +const { isEventHandled } = require('mail/static/src/utils/utils.js'); + +const patchMixin = require('web.patchMixin'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ChatWindow extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId); + const thread = chatWindow ? chatWindow.thread : undefined; + return { + chatWindow, + chatWindowHasNewMessageForm: chatWindow && chatWindow.hasNewMessageForm, + chatWindowIsDoFocus: chatWindow && chatWindow.isDoFocus, + chatWindowIsFocused: chatWindow && chatWindow.isFocused, + chatWindowIsFolded: chatWindow && chatWindow.isFolded, + chatWindowThreadView: chatWindow && chatWindow.threadView, + chatWindowVisibleIndex: chatWindow && chatWindow.visibleIndex, + chatWindowVisibleOffset: chatWindow && chatWindow.visibleOffset, + isDeviceMobile: this.env.messaging.device.isMobile, + localeTextDirection: this.env.messaging.locale.textDirection, + thread, + threadMassMailing: thread && thread.mass_mailing, + threadModel: thread && thread.model, + }; + }); + useUpdate({ func: () => this._update() }); + /** + * Reference of the header of the chat window. + * Useful to prevent click on header from wrongly focusing the window. + */ + this._chatWindowHeaderRef = useRef('header'); + /** + * Reference of the autocomplete input (new_message chat window only). + * Useful when focusing this chat window, which consists of focusing + * this input. + */ + this._inputRef = useRef('input'); + /** + * Reference of thread in the chat window (chat window with thread + * only). Useful when focusing this chat window, which consists of + * focusing this thread. Will likely focus the composer of thread, if + * it has one! + */ + this._threadRef = useRef('thread'); + this._onWillHideHomeMenu = this._onWillHideHomeMenu.bind(this); + this._onWillShowHomeMenu = this._onWillShowHomeMenu.bind(this); + // the following are passed as props to children + this._onAutocompleteSelect = this._onAutocompleteSelect.bind(this); + this._onAutocompleteSource = this._onAutocompleteSource.bind(this); + this._constructor(...args); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + mounted() { + this.env.messagingBus.on('will_hide_home_menu', this, this._onWillHideHomeMenu); + this.env.messagingBus.on('will_show_home_menu', this, this._onWillShowHomeMenu); + } + + willUnmount() { + this.env.messagingBus.off('will_hide_home_menu', this, this._onWillHideHomeMenu); + this.env.messagingBus.off('will_show_home_menu', this, this._onWillShowHomeMenu); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chat_window} + */ + get chatWindow() { + return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId); + } + + /** + * Get the content of placeholder for the autocomplete input of + * 'new_message' chat window. + * + * @returns {string} + */ + get newMessageFormInputPlaceholder() { + return this.env._t("Search user..."); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply visual position of the chat window. + * + * @private + */ + _applyVisibleOffset() { + const textDirection = this.env.messaging.locale.textDirection; + const offsetFrom = textDirection === 'rtl' ? 'left' : 'right'; + const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right'; + this.el.style[offsetFrom] = this.chatWindow.visibleOffset + 'px'; + this.el.style[oppositeFrom] = 'auto'; + } + + /** + * Focus this chat window. + * + * @private + */ + _focus() { + this.chatWindow.update({ + isDoFocus: false, + isFocused: true, + }); + if (this._inputRef.comp) { + this._inputRef.comp.focus(); + } + if (this._threadRef.comp) { + this._threadRef.comp.focus(); + } + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + _saveThreadScrollTop() { + if ( + !this._threadRef.comp || + !this.chatWindow.threadViewer || + !this.chatWindow.threadViewer.threadView + ) { + return; + } + if (this.chatWindow.threadViewer.threadView.componentHintList.length > 0) { + // the current scroll position is likely incorrect due to the + // presence of hints to adjust it + return; + } + this.chatWindow.threadViewer.saveThreadCacheScrollHeightAsInitial( + this._threadRef.comp.getScrollHeight() + ); + this.chatWindow.threadViewer.saveThreadCacheScrollPositionsAsInitial( + this._threadRef.comp.getScrollTop() + ); + } + + /** + * @private + */ + _update() { + if (!this.chatWindow) { + // chat window is being deleted + return; + } + if (this.chatWindow.isDoFocus) { + this._focus(); + } + this._applyVisibleOffset(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when selecting an item in the autocomplete input of the + * 'new_message' chat window. + * + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + async _onAutocompleteSelect(ev, ui) { + const chat = await this.env.messaging.getChat({ partnerId: ui.item.id }); + if (!chat) { + return; + } + this.env.messaging.chatWindowManager.openThread(chat, { + makeActive: true, + replaceNewMessage: true, + }); + } + + /** + * Called when typing in the autocomplete input of the 'new_message' chat + * window. + * + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAutocompleteSource(req, res) { + this.env.models['mail.partner'].imSearch({ + callback: (partners) => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: _.escape(req.term), + limit: 10, + }); + } + + /** + * Called when clicking on header of chat window. Usually folds the chat + * window. + * + * @private + * @param {CustomEvent} ev + */ + _onClickedHeader(ev) { + ev.stopPropagation(); + if (this.env.messaging.device.isMobile) { + return; + } + if (this.chatWindow.isFolded) { + this.chatWindow.unfold(); + this.chatWindow.focus(); + } else { + this._saveThreadScrollTop(); + this.chatWindow.fold(); + } + } + + /** + * Called when an element in the thread becomes focused. + * + * @private + * @param {FocusEvent} ev + */ + _onFocusinThread(ev) { + ev.stopPropagation(); + if (!this.chatWindow) { + // prevent crash on destroy + return; + } + this.chatWindow.update({ isFocused: true }); + } + + /** + * Focus out the chat window. + * + * @private + */ + _onFocusout() { + if (!this.chatWindow) { + // ignore focus out due to record being deleted + return; + } + this.chatWindow.update({ isFocused: false }); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + if (!this.chatWindow) { + // prevent crash during delete + return; + } + switch (ev.key) { + case 'Tab': + ev.preventDefault(); + if (ev.shiftKey) { + this.chatWindow.focusPreviousVisibleUnfoldedChatWindow(); + } else { + this.chatWindow.focusNextVisibleUnfoldedChatWindow(); + } + break; + case 'Escape': + if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) { + break; + } + if (isEventHandled(ev, 'Composer.closeEmojisPopover')) { + break; + } + ev.preventDefault(); + this.chatWindow.focusNextVisibleUnfoldedChatWindow(); + this.chatWindow.close(); + break; + } + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + async _onWillHideHomeMenu() { + this._saveThreadScrollTop(); + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + async _onWillShowHomeMenu() { + this._saveThreadScrollTop(); + } + +} + +Object.assign(ChatWindow, { + components, + defaultProps: { + hasCloseAsBackButton: false, + isExpandable: false, + isFullscreen: false, + }, + props: { + chatWindowLocalId: String, + hasCloseAsBackButton: Boolean, + isExpandable: Boolean, + isFullscreen: Boolean, + }, + template: 'mail.ChatWindow', +}); + +return patchMixin(ChatWindow); + +}); diff --git a/addons/mail/static/src/components/chat_window/chat_window.scss b/addons/mail/static/src/components/chat_window/chat_window.scss new file mode 100644 index 00000000..7b61cd3b --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.scss @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindow { + position: absolute; + bottom: 0; + display: flex; + flex-flow: column; + + &:not(.o-mobile) { + max-width: 100%; + max-height: 100%; + width: 325px; + + &.o-folded { + height: $o-mail-chat-window-header-height; + } + + &:not(.o-folded) { + height: 400px; + } + } + + &.o-mobile { + position: fixed; + } + + &.o-fullscreen { + height: 100%; + width: 100%; + } +} + +.o_ChatWindow_header { + flex: 0 0 auto; +} + +.o_ChatWindow_newMessageForm { + padding: 3px; + margin-top: 3px; + display: flex; + align-items: center; +} + +.o_ChatWindow_newMessageFormInput { + flex: 1 1 auto; +} + +.o_ChatWindow_newMessageFormLabel { + margin-right: 5px; + flex: 0 0 auto; +} + +.o_ChatWindow_thread { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindow { + background-color: $o-mail-thread-window-bg; + border-radius: 6px 6px 0 0; + box-shadow: -5px -5px 10px rgba(black, 0.09); + outline: none; + + &:not(.o-mobile) { + + &.o-focused { + box-shadow: -5px -5px 10px rgba(black, 0.18); + } + } + + + .o_Composer { + border: 0; + } +} + +.o_ChatWindow_header { + border-radius: 3px 3px 0 0; +} + +.o_ChatWindow_newMessageFormInput { + outline: none; + border: 1px solid gray('300'); // cancel firefox border on input focus +} + +.o_ChatWindow_thread .o_ThreadView_messageList { + font-size: 1rem; +} diff --git a/addons/mail/static/src/components/chat_window/chat_window.xml b/addons/mail/static/src/components/chat_window/chat_window.xml new file mode 100644 index 00000000..ad4a1096 --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.xml @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindow" owl="1"> + <div class="o_ChatWindow" tabindex="0" t-att-data-visible-index="chatWindow ? chatWindow.visibleIndex : undefined" + t-att-class="{ + 'o-focused': chatWindow and chatWindow.isFocused, + 'o-folded': chatWindow and chatWindow.isFolded, + 'o-fullscreen': props.isFullscreen, + 'o-mobile': env.messaging.device.isMobile, + 'o-new-message': chatWindow and !chatWindow.thread, + }" t-on-keydown="_onKeydown" t-on-focusout="_onFocusout" t-att-data-chat-window-local-id="chatWindow ? chatWindow.localId : undefined" t-att-data-thread-local-id="chatWindow ? (chatWindow.thread ? chatWindow.thread.localId : '') : undefined" + > + <t t-if="chatWindow"> + <ChatWindowHeader + class="o_ChatWindow_header" + chatWindowLocalId="chatWindow.localId" + hasCloseAsBackButton="props.hasCloseAsBackButton" + isExpandable="props.isExpandable" + t-on-o-clicked="_onClickedHeader" + t-ref="header" + /> + <t t-if="chatWindow.threadView"> + <ThreadView + class="o_ChatWindow_thread" + composerAttachmentsDetailsMode="'card'" + hasComposer="chatWindow.thread.model !== 'mail.box' and (!chatWindow.thread.mass_mailing or env.messaging.device.isMobile)" + hasComposerCurrentPartnerAvatar="false" + hasComposerSendButton="env.messaging.device.isMobile" + hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'" + threadViewLocalId="chatWindow.threadView.localId" + t-on-focusin="_onFocusinThread" + t-ref="thread" + /> + </t> + <t t-if="chatWindow.hasNewMessageForm"> + <div class="o_ChatWindow_newMessageForm"> + <span class="o_ChatWindow_newMessageFormLabel"> + To: + </span> + <AutocompleteInput + class="o_ChatWindow_newMessageFormInput" + placeholder="newMessageFormInputPlaceholder" + select="_onAutocompleteSelect" + source="_onAutocompleteSource" + t-ref="input" + /> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.js b/addons/mail/static/src/components/chat_window_header/chat_window_header.js new file mode 100644 index 00000000..ea560ca2 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.js @@ -0,0 +1,118 @@ +odoo.define('mail/static/src/components/chat_window_header/chat_window_header.js', function (require) { +'use strict'; + +const components = { + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatWindowHeader extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId); + const thread = chatWindow && chatWindow.thread; + return { + chatWindow, + chatWindowHasShiftLeft: chatWindow && chatWindow.hasShiftLeft, + chatWindowHasShiftRight: chatWindow && chatWindow.hasShiftRight, + chatWindowName: chatWindow && chatWindow.name, + isDeviceMobile: this.env.messaging.device.isMobile, + thread, + threadLocalMessageUnreadCounter: thread && thread.localMessageUnreadCounter, + threadMassMailing: thread && thread.mass_mailing, + threadModel: thread && thread.model, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chat_window} + */ + get chatWindow() { + return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const chatWindow = this.chatWindow; + this.trigger('o-clicked', { chatWindow }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + ev.stopPropagation(); + if (!this.chatWindow) { + return; + } + this.chatWindow.close(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickExpand(ev) { + ev.stopPropagation(); + this.chatWindow.expand(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickShiftLeft(ev) { + ev.stopPropagation(); + this.chatWindow.shiftLeft(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickShiftRight(ev) { + ev.stopPropagation(); + this.chatWindow.shiftRight(); + } + +} + +Object.assign(ChatWindowHeader, { + components, + defaultProps: { + hasCloseAsBackButton: false, + isExpandable: false, + }, + props: { + chatWindowLocalId: String, + hasCloseAsBackButton: Boolean, + isExpandable: Boolean, + }, + template: 'mail.ChatWindowHeader', +}); + +return ChatWindowHeader; + +}); diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.scss b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss new file mode 100644 index 00000000..c5c23634 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss @@ -0,0 +1,95 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowHeader { + display: flex; + align-items: center; + height: $o-mail-chat-window-header-height; + + &.o-mobile { + height: $o-mail-chat-window-header-height-mobile; + } +} + +.o_ChatWindowHeader_command { + padding: 0 8px; + display: flex; + height: 100%; + align-items: center; + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } +} + +.o_ChatWindowHeader_commandBack { + margin-right: 5px; +} + +.o_ChatWindowHeader_item { + margin: 0 3px; + + &.o_ChatWindowHeader_rightArea { + margin-right: 0; + } + + &:first-child { + margin-left: 10px; + + &.o_ChatWindowHeader_command { + margin-left: 0px; // no margin for commands + } + } + + &.o_ChatWindowHeader_rightArea:last-child .o_ChatWindowHeader_command { + margin-right: 0px; // no margin for commands + } +} + +.o_ChatWindowHeader_name { + max-height: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.o_ChatWindowHeader_rightArea { + display: flex; + height: 100%; + align-items: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindowHeader { + background-color: $o-brand-odoo; + color: white; + cursor: pointer; + + &:not(.o-mobile) { + + &:hover .o_ChatWindowHeader_command { + opacity: 0.7; + + &:hover { + opacity: 1; + } + } + + &:not(:hover) .o_ChatWindowHeader_command { + opacity: 0.5; + } + } + +} + +.o_ChatWindowHeader_command.o-mobile { + font-size: 1.3rem; +} + +.o_ChatWindowHeader_name { + user-select: none; +} diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.xml b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml new file mode 100644 index 00000000..b922da15 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowHeader" owl="1"> + <div class="o_ChatWindowHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClick"> + <t t-if="chatWindow"> + <t t-if="props.hasCloseAsBackButton"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandBack o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close conversation"> + <i class="fa fa-arrow-left"/> + </div> + </t> + <t t-if="chatWindow.thread and chatWindow.thread.model === 'mail.channel'"> + <ThreadIcon + class="o_ChatWindowHeader_icon o_ChatWindowHeader_item" + threadLocalId="chatWindow.thread.localId" + /> + </t> + <div class="o_ChatWindowHeader_item o_ChatWindowHeader_name" t-att-title="chatWindow.name"> + <t t-esc="chatWindow.name"/> + </div> + <t t-if="chatWindow.thread and chatWindow.thread.mass_mailing"> + <i class="fa fa-envelope-o" title="Messages are sent by email" role="img"/> + </t> + <t t-if="chatWindow.thread and chatWindow.thread.localMessageUnreadCounter > 0"> + <div class="o_ChatWindowHeader_counter o_ChatWindowHeader_item"> + (<t t-esc="chatWindow.thread.localMessageUnreadCounter"/>) + </div> + </t> + <div class="o-autogrow"/> + <div class="o_ChatWindowHeader_item o_ChatWindowHeader_rightArea"> + <t t-if="chatWindow.hasShiftLeft"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftLeft" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftLeft" title="Shift left"> + <i class="fa fa-angle-left"/> + </div> + </t> + <t t-if="chatWindow.hasShiftRight"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftRight" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftRight" title="Shift right"> + <i class="fa fa-angle-right"/> + </div> + </t> + <t t-if="props.isExpandable"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandExpand" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickExpand" title="Open in Discuss"> + <i class="fa fa-expand"/> + </div> + </t> + <t t-if="!props.hasCloseAsBackButton"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close chat window"> + <i class="fa fa-close"/> + </div> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js new file mode 100644 index 00000000..926ca083 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js @@ -0,0 +1,141 @@ +odoo.define('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js', function (require) { +'use strict'; + +const components = { + ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ChatWindowHiddenMenu extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatWindowManager = this.env.messaging.chatWindowManager; + const device = this.env.messaging.device; + const locale = this.env.messaging.locale; + return { + chatWindowManager: chatWindowManager ? chatWindowManager.__state : undefined, + device: device ? device.__state : undefined, + localeTextDirection: locale ? locale.textDirection : undefined, + }; + }); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + /** + * Reference of the dropup list. Useful to auto-set max height based on + * browser screen height. + */ + this._listRef = useRef('list'); + /** + * The intent of the toggle button depends on the last rendered state. + */ + this._wasMenuOpen; + } + + mounted() { + this._apply(); + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + patched() { + this._apply(); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _apply() { + this._applyListHeight(); + this._applyOffset(); + this._wasMenuOpen = this.env.messaging.chatWindowManager.isHiddenMenuOpen; + } + + /** + * @private + */ + _applyListHeight() { + const device = this.env.messaging.device; + const height = device.globalWindowInnerHeight / 2; + this._listRef.el.style['max-height'] = `${height}px`; + } + + /** + * @private + */ + _applyOffset() { + const textDirection = this.env.messaging.locale.textDirection; + const offsetFrom = textDirection === 'rtl' ? 'left' : 'right'; + const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right'; + const offset = this.env.messaging.chatWindowManager.visual.hidden.offset; + this.el.style[offsetFrom] = `${offset}px`; + this.el.style[oppositeFrom] = 'auto'; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Closes the menu when clicking outside. + * Must be done as capture to avoid stop propagation. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + if (this.el.contains(ev.target)) { + return; + } + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickToggle(ev) { + if (this._wasMenuOpen) { + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } else { + this.env.messaging.chatWindowManager.openHiddenMenu(); + } + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.chat_window} ev.detail.chatWindow + */ + _onClickedChatWindow(ev) { + const chatWindow = ev.detail.chatWindow; + chatWindow.makeActive(); + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } + +} + +Object.assign(ChatWindowHiddenMenu, { + components, + props: {}, + template: 'mail.ChatWindowHiddenMenu', +}); + +return ChatWindowHiddenMenu; + +}); diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss new file mode 100644 index 00000000..119d6184 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowHiddenMenu { + position: fixed; + bottom: 0; + display: flex; + width: 50px; + height: 28px; + align-items: stretch; +} + +.o_ChatWindowHiddenMenu_chatWindowHeader { + max-width: 200px; +} + +.o_ChatWindowHiddenMenu_dropdownToggle { + display: flex; + align-items: center; + justify-content: center; + flex: 1 1 auto; + max-width: 100%; +} + +.o_ChatWindowHiddenMenu_dropdownToggleIcon { + margin-right: 1px; +} + +.o_ChatWindowHiddenMenu_dropdownToggleItem { + margin: 0 3px; +} + +.o_ChatWindowHiddenMenu_list { + overflow: auto; + margin: 0; + padding: 0; +} + +.o_ChatWindowHiddenMenu_listItem { + + &:not(:last-child) { + margin-bottom: 1px; + } +} + +.o_ChatWindowHiddenMenu_unreadCounter { + position: absolute; + right: 0; + top: 0; + transform: translate(50%, -50%); + z-index: 1001; // on top of bootstrap dropup menu +} + +.o_ChatWindowHiddenMenu_windowCounter { + overflow: hidden; + text-overflow: ellipsis; + margin-left: 1px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindowHiddenMenu { + background-color: gray('900'); + border-radius: 6px 6px 0 0; + color: white; + cursor: pointer; +} + +.o_ChatWindowHiddenMenu_chatWindowHeader { + opacity: 0.95; + + &:hover { + opacity: 1; + } +} + +.o_ChatWindowHiddenMenu_dropdownToggle.show { + opacity: 0.5; +} + +.o_ChatWindowHiddenMenu_unreadCounter { + background-color: $o-brand-primary; +} + +.o_ChatWindowHiddenMenu_windowCounter { + user-select: none; +} diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml new file mode 100644 index 00000000..943fc71d --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowHiddenMenu" owl="1"> + <div class="dropup o_ChatWindowHiddenMenu"> + <div class="dropdown-toggle o_ChatWindowHiddenMenu_dropdownToggle" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" t-on-click="_onClickToggle"> + <div class="fa fa-comments-o o_ChatWindowHiddenMenu_dropdownToggleIcon o_ChatWindowHiddenMenu_dropdownToggleItem"/> + <div class="o_ChatWindowHiddenMenu_dropdownToggleItem o_ChatWindowHiddenMenu_windowCounter"> + <t t-esc="env.messaging.chatWindowManager.allOrderedHidden.length"/> + </div> + </div> + <ul class="dropdown-menu dropdown-menu-right o_ChatWindowHiddenMenu_list" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" role="menu" t-ref="list"> + <t t-foreach="env.messaging.chatWindowManager.allOrderedHidden" t-as="chatWindow" t-key="chatWindow.localId"> + <li class="o_ChatWindowHiddenMenu_listItem" role="menuitem"> + <ChatWindowHeader + class="o_ChatWindowHiddenMenu_chatWindowHeader" + chatWindowLocalId="chatWindow.localId" + t-on-o-clicked="_onClickedChatWindow" + /> + </li> + </t> + </ul> + <t t-if="env.messaging.chatWindowManager.unreadHiddenConversationAmount > 0"> + <div class="badge badge-pill o_ChatWindowHiddenMenu_unreadCounter"> + <t t-esc="env.messaging.chatWindowManager.unreadHiddenConversationAmount"/> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js new file mode 100644 index 00000000..a0c49a96 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js @@ -0,0 +1,51 @@ +odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager.js', function (require) { +'use strict'; + +const components = { + ChatWindow: require('mail/static/src/components/chat_window/chat_window.js'), + ChatWindowHiddenMenu: require('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatWindowManager extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const chatWindowManager = this.env.messaging && this.env.messaging.chatWindowManager; + const allOrderedVisible = chatWindowManager + ? chatWindowManager.allOrderedVisible + : []; + return { + allOrderedVisible, + allOrderedVisibleThread: allOrderedVisible.map(chatWindow => chatWindow.thread), + chatWindowManager, + chatWindowManagerHasHiddenChatWindows: chatWindowManager && chatWindowManager.hasHiddenChatWindows, + isMessagingInitialized: this.env.isMessagingInitialized(), + }; + }, { + compareDepth: { + allOrderedVisible: 1, + allOrderedVisibleThread: 1, + }, + }); + } + +} + +Object.assign(ChatWindowManager, { + components, + props: {}, + template: 'mail.ChatWindowManager', +}); + +return ChatWindowManager; + +}); diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss new file mode 100644 index 00000000..1e8abf54 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowManager { + bottom: 0; + right: 0; + display: flex; + flex-direction: row-reverse; + z-index: 1000; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml new file mode 100644 index 00000000..8e2bd6bf --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowManager" owl="1"> + <div class="o_ChatWindowManager"> + <t t-if="env.isMessagingInitialized()"> + <!-- Note: DOM elements are ordered from left to right --> + <t t-if="env.messaging.chatWindowManager.hasHiddenChatWindows"> + <ChatWindowHiddenMenu class="o_ChatWindowManager_hiddenMenu"/> + </t> + <t t-foreach="env.messaging.chatWindowManager.allOrderedVisible" t-as="chatWindow" t-key="chatWindow.localId"> + <ChatWindow + chatWindowLocalId="chatWindow.localId" + hasCloseAsBackButton="env.messaging.device.isMobile" + isExpandable="!env.messaging.device.isMobile and !!chatWindow.thread" + isFullscreen="env.messaging.device.isMobile" + /> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js new file mode 100644 index 00000000..ce82f2bb --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js @@ -0,0 +1,2423 @@ +odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager_tests.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { + afterEach, + afterNextRender, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const { + file: { createFile, inputFiles }, + dom: { triggerEvent }, +} = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chat_window_manager', {}, function () { +QUnit.module('chat_window_manager_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { afterEvent, env, widget } = await start(Object.assign( + { hasChatWindow: true, hasMessagingMenu: true }, + params, + { data: this.data } + )); + this.debug = params && params.debug; + this.afterEvent = afterEvent; + this.env = env; + this.widget = widget; + }; + + /** + * Simulates the external behaviours & DOM changes implied by hiding home menu. + * Needed to assert validity of tests at technical level (actual code of home menu could not + * be used in these tests). + */ + this.hideHomeMenu = async () => { + await this.env.bus.trigger('will_hide_home_menu'); + await this.env.bus.trigger('hide_home_menu'); + }; + + /** + * Simulates the external behaviours & DOM changes implied by showing home menu. + * Needed to assert validity of tests at technical level (actual code of home menu could not + * be used in these tests). + */ + this.showHomeMenu = async () => { + await this.env.bus.trigger('will_show_home_menu'); + const $frag = document.createDocumentFragment(); + // in real condition, chat window will be removed and put in a fragment then + // reinserted into DOM + const selector = this.debug ? 'body' : '#qunit-fixture'; + $(selector).contents().appendTo($frag); + await this.env.bus.trigger('show_home_menu'); + $(selector).append($frag); + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('[technical] messaging not created', async function (assert) { + /** + * Creation of messaging in env is async due to generation of models being + * async. Generation of models is async because it requires parsing of all + * JS modules that contain pieces of model definitions. + * + * Time of having no messaging is very short, almost imperceptible by user + * on UI, but the display should not crash during this critical time period. + */ + assert.expect(2); + + const messagingBeforeCreationDeferred = makeDeferred(); + await this.start({ + messagingBeforeCreationDeferred, + waitUntilMessagingCondition: 'none', + }); + assert.containsOnce( + document.body, + '.o_ChatWindowManager', + "should have chat window manager even when messaging is not yet created" + ); + + // simulate messaging being created + messagingBeforeCreationDeferred.resolve(); + await nextAnimationFrame(); + + assert.containsOnce( + document.body, + '.o_ChatWindowManager', + "should still contain chat window manager after messaging has been created" + ); +}); + +QUnit.test('initial mount', async function (assert) { + assert.expect(1); + + await this.start(); + assert.containsOnce( + document.body, + '.o_ChatWindowManager', + "should have chat window manager" + ); +}); + +QUnit.test('chat window new message: basic rendering', async function (assert) { + assert.expect(10); + + await this.start(); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header`).length, + 1, + "should have a header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_name`).length, + 1, + "should have name part in header" + ); + assert.strictEqual( + document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_name`).textContent, + "New message", + "should display 'new message' in the header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_command`).length, + 1, + "should have 1 command in header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).length, + 1, + "should have command to close chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageForm`).length, + 1, + "should have a new message chat window container" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageFormLabel`).length, + 1, + "should have a part in selection with label" + ); + assert.strictEqual( + document.querySelector(`.o_ChatWindow_newMessageFormLabel`).textContent.trim(), + "To:", + "should have label 'To:' in selection" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageFormInput`).length, + 1, + "should have an input in selection" + ); +}); + +QUnit.test('chat window new message: focused on open', async function (assert) { + assert.expect(2); + + await this.start(); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'), + "chat window should be focused" + ); + assert.ok( + document.activeElement, + document.querySelector(`.o_ChatWindow_newMessageFormInput`), + "chat window focused = selection input focused" + ); +}); + +QUnit.test('chat window new message: close', async function (assert) { + assert.expect(1); + + await this.start(); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 0, + "chat window should be closed" + ); +}); + +QUnit.test('chat window new message: fold', async function (assert) { + assert.expect(6); + + await this.start(); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.doesNotHaveClass( + document.querySelector(`.o_ChatWindow`), + 'o-folded', + "chat window should not be folded by default" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_newMessageForm', + "chat window should have new message form" + ); + + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.hasClass( + document.querySelector(`.o_ChatWindow`), + 'o-folded', + "chat window should become folded" + ); + assert.containsNone( + document.body, + '.o_ChatWindow_newMessageForm', + "chat window should not have new message form" + ); + + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.doesNotHaveClass( + document.querySelector(`.o_ChatWindow`), + 'o-folded', + "chat window should become unfolded" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_newMessageForm', + "chat window should have new message form" + ); +}); + +QUnit.test('open chat from "new message" chat window should open chat in place of this "new message" chat window', async function (assert) { + /** + * InnerWith computation uses following info: + * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method) + * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1920px + * + * Enough space for 3 visible chat windows: + * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 1920 + */ + assert.expect(11); + + this.data['res.partner'].records.push({ id: 131, name: "Partner 131" }); + this.data['res.users'].records.push({ partner_id: 131 }); + this.data['mail.channel'].records.push( + { is_minimized: true }, + { is_minimized: true }, + ); + const imSearchDef = makeDeferred(); + await this.start({ + env: { + browser: { + innerWidth: 1920, + }, + }, + async mockRPC(route, args) { + const res = await this._super(...arguments); + if (args.method === 'im_search') { + imSearchDef.resolve(); + } + return res; + } + }); + assert.containsN( + document.body, + '.o_ChatWindow', + 2, + "should have 2 chat windows initially" + ); + assert.containsNone( + document.body, + '.o_ChatWindow.o-new-message', + "should not have any 'new message' chat window initially" + ); + + // open "new message" chat window + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow.o-new-message', + "should have 'new message' chat window after clicking 'new message' in messaging menu" + ); + assert.containsN( + document.body, + '.o_ChatWindow', + 3, + "should have 3 chat window after opening 'new message' chat window", + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_newMessageFormInput', + "'new message' chat window should have new message form input" + ); + assert.hasClass( + document.querySelector('.o_ChatWindow[data-visible-index="2"]'), + 'o-new-message', + "'new message' chat window should be the last chat window initially", + ); + + await afterNextRender(() => + document.querySelector('.o_ChatWindow[data-visible-index="2"] .o_ChatWindowHeader_commandShiftRight').click() + ); + assert.hasClass( + document.querySelector('.o_ChatWindow[data-visible-index="1"]'), + 'o-new-message', + "'new message' chat window should have moved to the middle after clicking shift previous", + ); + + // search for a user in "new message" autocomplete + document.execCommand('insertText', false, "131"); + document.querySelector(`.o_ChatWindow_newMessageFormInput`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ChatWindow_newMessageFormInput`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + // Wait for search RPC to be resolved. The following await lines are + // necessary because autocomplete is an external lib therefore it is not + // possible to use `afterNextRender`. + await imSearchDef; + await nextAnimationFrame(); + const link = document.querySelector('.ui-autocomplete .ui-menu-item a'); + assert.ok( + link, + "should have autocomplete suggestion after typing on 'new message' input" + ); + assert.strictEqual( + link.textContent, + "Partner 131", + "autocomplete suggestion should target the partner matching search term" + ); + + await afterNextRender(() => link.click()); + assert.containsNone( + document.body, + '.o_ChatWindow.o-new-message', + "should have removed the 'new message' chat window after selecting a partner" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="1"] .o_ChatWindowHeader_name').textContent, + "Partner 131", + "chat window with selected partner should be opened in position where 'new message' chat window was, which is in the middle" + ); +}); + +QUnit.test('new message chat window should close on selecting the user if chat with the user is already open', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ id: 131, name: "Partner 131"}); + this.data['res.users'].records.push({ id: 12, partner_id: 131 }); + this.data['mail.channel'].records.push({ + channel_type: "chat", + id: 20, + is_minimized: true, + members: [this.data.currentPartnerId, 131], + name: "Partner 131", + public: 'private', + state: 'open', + }); + const imSearchDef = makeDeferred(); + await this.start({ + async mockRPC(route, args) { + const res = await this._super(...arguments); + if (args.method === 'im_search') { + imSearchDef.resolve(); + } + return res; + }, + }); + + // open "new message" chat window + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_newMessageButton`).click()); + + // search for a user in "new message" autocomplete + document.execCommand('insertText', false, "131"); + document.querySelector(`.o_ChatWindow_newMessageFormInput`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ChatWindow_newMessageFormInput`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + // Wait for search RPC to be resolved. The following await lines are + // necessary because autocomplete is an external lib therefore it is not + // possible to use `afterNextRender`. + await imSearchDef; + await nextAnimationFrame(); + const link = document.querySelector('.ui-autocomplete .ui-menu-item a'); + + await afterNextRender(() => link.click()); + assert.containsNone( + document.body, + '.o_ChatWindow_newMessageFormInput', + "'new message' chat window should not be there" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have only one chat window after selecting user whose chat is already open", + ); +}); + +QUnit.test('new message autocomplete should automatically select first result', async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ id: 131, name: "Partner 131" }); + this.data['res.users'].records.push({ partner_id: 131 }); + const imSearchDef = makeDeferred(); + await this.start({ + async mockRPC(route, args) { + const res = await this._super(...arguments); + if (args.method === 'im_search') { + imSearchDef.resolve(); + } + return res; + }, + }); + + // open "new message" chat window + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + + // search for a user in "new message" autocomplete + document.execCommand('insertText', false, "131"); + document.querySelector(`.o_ChatWindow_newMessageFormInput`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ChatWindow_newMessageFormInput`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + // Wait for search RPC to be resolved. The following await lines are + // necessary because autocomplete is an external lib therefore it is not + // possible to use `afterNextRender`. + await imSearchDef; + await nextAnimationFrame(); + assert.hasClass( + document.querySelector('.ui-autocomplete .ui-menu-item a'), + 'ui-state-active', + "first autocomplete result should be automatically selected", + ); +}); + +QUnit.test('chat window: basic rendering', async function (assert) { + assert.expect(11); + + // channel that is expected to be found in the messaging menu + // with random unique id and name that will be asserted during the test + this.data['mail.channel'].records.push({ id: 20, name: "General" }); + await this.start(); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_NotificationList_preview`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + const chatWindow = document.querySelector(`.o_ChatWindow`); + assert.strictEqual( + chatWindow.dataset.threadLocalId, + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId, + "should have open a chat window of channel" + ); + assert.strictEqual( + chatWindow.querySelectorAll(`:scope .o_ChatWindow_header`).length, + 1, + "should have header part" + ); + const chatWindowHeader = chatWindow.querySelector(`:scope .o_ChatWindow_header`); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have thread icon in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_name`).length, + 1, + "should have thread name in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelector(`:scope .o_ChatWindowHeader_name`).textContent, + "General", + "should have correct thread name in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_command`).length, + 2, + "should have 2 commands in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandExpand`).length, + 1, + "should have command to expand thread in discuss" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandClose`).length, + 1, + "should have command to close chat window" + ); + assert.strictEqual( + chatWindow.querySelectorAll(`:scope .o_ChatWindow_thread`).length, + 1, + "should have part to display thread content inside chat window" + ); + assert.ok( + chatWindow.querySelector(`:scope .o_ChatWindow_thread`).classList.contains('o_ThreadView'), + "thread part should use component ThreadView" + ); +}); + +QUnit.test('chat window: fold', async function (assert) { + assert.expect(9); + + // channel that is expected to be found in the messaging menu + // with random UUID, will be asserted during the test + this.data['mail.channel'].records.push({ uuid: 'channel-uuid' }); + await this.start({ + mockRPC(route, args) { + if (args.method === 'channel_fold') { + assert.step(`rpc:${args.method}/${args.kwargs.state}`); + } + return this._super(...arguments); + }, + }); + // Open Thread + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_thread', + "chat window should have a thread" + ); + assert.verifySteps( + ['rpc:channel_fold/open'], + "should sync fold state 'open' with server after opening chat window" + ); + + // Fold chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.verifySteps( + ['rpc:channel_fold/folded'], + "should sync fold state 'folded' with server after folding chat window" + ); + assert.containsNone( + document.body, + '.o_ChatWindow_thread', + "chat window should not have any thread" + ); + + // Unfold chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.verifySteps( + ['rpc:channel_fold/open'], + "should sync fold state 'open' with server after unfolding chat window" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_thread', + "chat window should have a thread" + ); +}); + +QUnit.test('chat window: open / close', async function (assert) { + assert.expect(10); + + // channel that is expected to be found in the messaging menu + // with random UUID, will be asserted during the test + this.data['mail.channel'].records.push({ uuid: 'channel-uuid' }); + await this.start({ + mockRPC(route, args) { + if (args.method === 'channel_fold') { + assert.step(`rpc:channel_fold/${args.kwargs.state}`); + } + return this._super(...arguments); + }, + }); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should not have a chat window initially" + ); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have a chat window after clicking on thread preview" + ); + assert.verifySteps( + ['rpc:channel_fold/open'], + "should sync fold state 'open' with server after opening chat window" + ); + + // Close chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click()); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should not have a chat window after closing it" + ); + assert.verifySteps( + ['rpc:channel_fold/closed'], + "should sync fold state 'closed' with server after closing chat window" + ); + + // Reopen chat window + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have a chat window again after clicking on thread preview again" + ); + assert.verifySteps( + ['rpc:channel_fold/open'], + "should sync fold state 'open' with server after opening chat window again" + ); +}); + +QUnit.test('Mobile: opening a chat window should not update channel state on the server', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 20, + state: 'closed', + }); + await this.start({ + env: { + device: { + isMobile: true, + }, + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => document.querySelector(`.o_NotificationList_preview`).click()); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have a chat window after clicking on thread preview" + ); + const channels = await this.env.services.rpc({ + model: 'mail.channel', + method: 'read', + args: [20], + }, { shadow: true }); + assert.strictEqual( + channels[0].state, + 'closed', + 'opening a chat window in mobile should not update channel state on the server', + ); +}); + +QUnit.test('Mobile: closing a chat window should not update channel state on the server', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ + id: 20, + state: 'open', + }); + await this.start({ + env: { + device: { + isMobile: true, + }, + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => document.querySelector(`.o_NotificationList_preview`).click()); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have a chat window after clicking on thread preview" + ); + // Close chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click()); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should not have a chat window after closing it" + ); + const channels = await this.env.services.rpc({ + model: 'mail.channel', + method: 'read', + args: [20], + }, { shadow: true }); + assert.strictEqual( + channels[0].state, + 'open', + 'closing the chat window should not update channel state on the server', + ); +}); + +QUnit.test("Mobile: chat window shouldn't open automatically after receiving a new message", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ id: 10, name: "Demo" }); + this.data['res.users'].records.push({ + id: 42, + partner_id: 10, + }); + this.data['mail.channel'].records = [ + { + channel_type: "chat", + id: 10, + members: [this.data.currentPartnerId, 10], + uuid: 'channel-10-uuid', + }, + ]; + await this.start({ + env: { + device: { + isMobile: true, + }, + }, + }); + + // simulate receiving a message + await afterNextRender(() => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hu", + uuid: 'channel-10-uuid', + }, + })); + assert.containsNone( + document.body, + '.o_ChatWindow', + "On mobile, the chat window shouldn't open automatically after receiving a new message" + ); +}); + +QUnit.test('chat window: close on ESCAPE', async function (assert) { + assert.expect(10); + + // expected partner to be found by mention during the test + this.data['res.partner'].records.push({ name: "TestPartner" }); + // a chat window with thread is expected to be initially open for this test + this.data['mail.channel'].records.push({ is_minimized: true }); + await this.start({ + mockRPC(route, args) { + if (args.method === 'channel_fold') { + assert.step(`rpc:channel_fold/${args.kwargs.state}`); + } + return this._super(...arguments); + }, + }); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "chat window should be opened initially" + ); + + await afterNextRender(() => + document.querySelector(`.o_Composer_buttonEmojis`).click() + ); + assert.containsOnce( + document.body, + '.o_EmojisPopover', + "emojis popover should be opened after click on emojis button" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_Composer_buttonEmojis`).dispatchEvent(ev); + }); + assert.containsNone( + document.body, + '.o_EmojisPopover', + "emojis popover should be closed after pressing escape on emojis button" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "chat window should still be opened after pressing escape on emojis button" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display mention suggestions on typing '@'" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev); + }); + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "mention suggestion should be closed after pressing escape on mention suggestion" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "chat window should still be opened after pressing escape on mention suggestion" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev); + }); + assert.containsNone( + document.body, + '.o_ChatWindow', + "chat window should be closed after pressing escape if there was no other priority escape handler" + ); + assert.verifySteps(['rpc:channel_fold/closed']); +}); + +QUnit.test('focus next visible chat window when closing current chat window with ESCAPE', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method) + * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1920px + * + * Enough space for 2 visible chat windows: + * 10 + 325 + 5 + 325 + 10 = 670 < 1920 + */ + assert.expect(4); + + // 2 chat windows with thread are expected to be initially open for this test + this.data['mail.channel'].records.push( + { is_minimized: true, state: 'open' }, + { is_minimized: true, state: 'open' } + ); + await this.start({ + env: { + browser: { + innerWidth: 1920, + }, + }, + }); + assert.containsN( + document.body, + '.o_ChatWindow .o_ComposerTextInput_textarea', + 2, + "2 chat windows should be present initially" + ); + assert.containsNone( + document.body, + '.o_ChatWindow.o-folded', + "both chat windows should be open" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(ev); + }); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "only one chat window should remain after pressing escape on first chat window" + ); + assert.hasClass( + document.querySelector('.o_ChatWindow'), + 'o-focused', + "next visible chat window should be focused after pressing escape on first chat window" + ); +}); + +QUnit.test('[technical] chat window: composer state conservation on toggle home menu', async function (assert) { + // technical as show/hide home menu simulation are involved and home menu implementation + // have side-effects on DOM that may make chat window components not work + assert.expect(7); + + // channel that is expected to be found in the messaging menu + // with random unique id that is needed to link messages + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + // Set content of the composer of the chat window + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, 'XDU for the win !'); + }); + assert.containsNone( + document.body, + '.o_Composer .o_Attachment', + "composer should have no attachment initially" + ); + // Set attachments of the composer + const files = [ + await createFile({ + name: 'text state conservation on toggle home menu.txt', + content: 'hello, world', + contentType: 'text/plain', + }), + await createFile({ + name: 'text2 state conservation on toggle home menu.txt', + content: 'hello, xdu is da best man', + contentType: 'text/plain', + }) + ]; + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + files + ) + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "XDU for the win !", + "chat window composer initial text input should contain 'XDU for the win !'" + ); + assert.containsN( + document.body, + '.o_Composer .o_Attachment', + 2, + "composer should have 2 total attachments after adding 2 attachments" + ); + + await this.hideHomeMenu(); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "XDU for the win !", + "Chat window composer should still have the same input after hiding home menu" + ); + assert.containsN( + document.body, + '.o_Composer .o_Attachment', + 2, + "Chat window composer should have 2 attachments after hiding home menu" + ); + + // Show home menu + await this.showHomeMenu(); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "XDU for the win !", + "chat window composer should still have the same input showing home menu" + ); + assert.containsN( + document.body, + '.o_Composer .o_Attachment', + 2, + "Chat window composer should have 2 attachments showing home menu" + ); +}); + +QUnit.test('[technical] chat window: scroll conservation on toggle home menu', async function (assert) { + // technical as show/hide home menu simulation are involved and home menu implementation + // have side-effects on DOM that may make chat window components not work + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + for (let i = 0; i < 10; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + }); + } + await this.start(); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_NotificationList_preview').click(), + message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); + // Set a scroll position to chat window + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + document.querySelector(`.o_ThreadView_messageList`).scrollTop = 142; + }, + message: "should wait until channel 20 scrolled to 142 after setting this value manually", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === 142 + ); + }, + }); + await afterNextRender(() => this.hideHomeMenu()); + assert.strictEqual( + document.querySelector(`.o_ThreadView_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same after home menu is hidden" + ); + + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => this.showHomeMenu(), + message: "should wait until channel 20 restored its scroll to 142 after showing the home menu", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === 142 + ); + }, + }); + assert.strictEqual( + document.querySelector(`.o_ThreadView_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same after home menu is shown" + ); +}); + +QUnit.test('open 2 different chat windows: enough screen width [REQUIRE FOCUS]', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method) + * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1920px + * + * Enough space for 2 visible chat windows: + * 10 + 325 + 5 + 325 + 10 = 670 < 1920 + */ + assert.expect(8); + + // 2 channels are expected to be found in the messaging menu, each with a + // random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 10 }, { id: 20 }); + await this.start({ + env: { + browser: { + innerWidth: 1920, // enough to fit at least 2 chat windows + }, + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "chat window of chat should be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).classList.contains('o-focused'), + "chat window of chat should have focus" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open a new chat window" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "chat window of channel should be open" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "chat window of chat should still be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).classList.contains('o-focused'), + "chat window of channel should have focus" + ); + assert.notOk( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).classList.contains('o-focused'), + "chat window of chat should no longer have focus" + ); +}); + +QUnit.test('open 2 chat windows: check shift operations are available', async function (assert) { + assert.expect(9); + + // 2 channels are expected to be found in the messaging menu + // only their existence matters, data are irrelevant + this.data['mail.channel'].records.push({}, {}); + await this.start(); + + await afterNextRender(() => { + document.querySelector('.o_MessagingMenu_toggler').click(); + }); + await afterNextRender(() => { + document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[0].click(); + }); + await afterNextRender(() => { + document.querySelector('.o_MessagingMenu_toggler').click(); + }); + await afterNextRender(() => { + document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[1].click(); + }); + assert.containsN( + document.body, + '.o_ChatWindow', + 2, + "should have opened 2 chat windows" + ); + assert.containsOnce( + document.querySelectorAll('.o_ChatWindow')[0], + '.o_ChatWindowHeader_commandShiftLeft', + "first chat window should be allowed to shift left" + ); + assert.containsNone( + document.querySelectorAll('.o_ChatWindow')[0], + '.o_ChatWindowHeader_commandShiftRight', + "first chat window should not be allowed to shift right" + ); + assert.containsNone( + document.querySelectorAll('.o_ChatWindow')[1], + '.o_ChatWindowHeader_commandShiftLeft', + "second chat window should not be allowed to shift left" + ); + assert.containsOnce( + document.querySelectorAll('.o_ChatWindow')[1], + '.o_ChatWindowHeader_commandShiftRight', + "second chat window should be allowed to shift right" + ); + + const initialFirstChatWindowThreadLocalId = + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId; + const initialSecondChatWindowThreadLocalId = + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId; + await afterNextRender(() => { + document.querySelectorAll('.o_ChatWindow')[0] + .querySelector(':scope .o_ChatWindowHeader_commandShiftLeft') + .click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "First chat window should be second after it has been shift left" + ); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "Second chat window should be first after the first has been shifted left" + ); + + await afterNextRender(() => { + document.querySelectorAll('.o_ChatWindow')[1] + .querySelector(':scope .o_ChatWindowHeader_commandShiftRight') + .click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "First chat window should be back at first place after being shifted left then right" + ); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "Second chat window should be back at second place after first one has been shifted left then right" + ); +}); + +QUnit.test('open 2 folded chat windows: check shift operations are available', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: 900px) + * (others: @see `mail/static/src/models/chat_window_manager/chat_window_manager.js:visual`) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - global width: 900px + * + * 2 visible chat windows + hidden menu: + * 10 + 325 + 5 + 325 + 10 = 675 < 900 + */ + assert.expect(13); + + this.data['res.partner'].records.push({ id: 7, name: "Demo" }); + const channel = { + channel_type: "channel", + is_minimized: true, + is_pinned: true, + state: 'folded', + }; + const chat = { + channel_type: "chat", + is_minimized: true, + is_pinned: true, + members: [this.data.currentPartnerId, 7], + state: 'folded', + }; + this.data['mail.channel'].records.push(channel, chat); + await this.start({ + env: { + browser: { + innerWidth: 900, + }, + }, + }); + + assert.containsN( + document.body, + '.o_ChatWindow', + 2, + "should have opened 2 chat windows initially" + ); + assert.hasClass( + document.querySelector('.o_ChatWindow[data-visible-index="0"]'), + 'o-folded', + "first chat window should be folded" + ); + assert.hasClass( + document.querySelector('.o_ChatWindow[data-visible-index="1"]'), + 'o-folded', + "second chat window should be folded" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow .o_ChatWindowHeader_commandShiftLeft', + "there should be only one chat window allowed to shift left even if folded" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow .o_ChatWindowHeader_commandShiftRight', + "there should be only one chat window allowed to shift right even if folded" + ); + + const initialFirstChatWindowThreadLocalId = + document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId; + const initialSecondChatWindowThreadLocalId = + document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId; + await afterNextRender(() => + document.querySelector('.o_ChatWindowHeader_commandShiftLeft').click() + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "First chat window should be second after it has been shift left" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "Second chat window should be first after the first has been shifted left" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatWindowHeader_commandShiftLeft').click() + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "First chat window should be back at first place" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "Second chat window should be back at second place" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatWindowHeader_commandShiftRight').click() + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "First chat window should be second after it has been shift right" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "Second chat window should be first after the first has been shifted right" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatWindowHeader_commandShiftRight').click() + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "First chat window should be back at first place" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "Second chat window should be back at second place" + ); +}); + +QUnit.test('open 3 different chat windows: not enough screen width', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: 900px) + * (others: @see `mail/static/src/models/chat_window_manager/chat_window_manager.js:visual`) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1080px + * + * Enough space for 2 visible chat windows, and one hidden chat window: + * 3 visible chat windows: + * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 900 + * 2 visible chat windows + hidden menu: + * 10 + 325 + 5 + 325 + 10 + 200 + 5 = 875 < 900 + */ + assert.expect(12); + + // 3 channels are expected to be found in the messaging menu, each with a + // random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 1 }, { id: 2 }, { id: 3 }); + await this.start({ + env: { + browser: { + innerWidth: 900, // enough to fit 2 chat windows but not 3 + }, + }, + }); + + // open, from systray menu, chat windows of channels with Id 1, 2, then 3 + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 1, + model: 'mail.channel', + }).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open 1 visible chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 0, + "should not have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 2, + model: 'mail.channel', + }).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open 2 visible chat windows" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 0, + "should not have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 3, + model: 'mail.channel', + }).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open 2 visible chat windows" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 1, + "should have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 1, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "chat window of channel 1 should be open" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 3, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "chat window of channel 3 should be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 3, + model: 'mail.channel', + }).localId + }"] + `).classList.contains('o-focused'), + "chat window of channel 3 should have focus" + ); +}); + +QUnit.test('chat window: switch on TAB', async function (assert) { + assert.expect(10); + + // 2 channels are expected to be found in the messaging menu + // with random unique id and name that will be asserted during the test + this.data['mail.channel'].records.push( + { id: 1, name: "channel1" }, + { id: 2, name: "channel2" } + ); + await this.start(); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 1, + model: 'mail.channel', + }).localId + }"]` + ).click() + ); + + assert.containsOnce(document.body, '.o_ChatWindow', "Only 1 chatWindow must be opened"); + const chatWindow = document.querySelector('.o_ChatWindow'); + assert.strictEqual( + chatWindow.querySelector('.o_ChatWindowHeader_name').textContent, + 'channel1', + "The name of the only chatWindow should be 'channel1' (channel with ID 1)" + ); + assert.strictEqual( + chatWindow.querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The chatWindow composer must have focus" + ); + + await afterNextRender(() => + triggerEvent( + chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.strictEqual( + chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'), + document.activeElement, + "The chatWindow composer still has focus" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 2, + model: 'mail.channel', + }).localId + }"]` + ).click() + ); + + assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows must be opened"); + const chatWindows = document.querySelectorAll('.o_ChatWindow'); + assert.strictEqual( + chatWindows[0].querySelector('.o_ChatWindowHeader_name').textContent, + 'channel1', + "The name of the 1st chatWindow should be 'channel1' (channel with ID 1)" + ); + assert.strictEqual( + chatWindows[1].querySelector('.o_ChatWindowHeader_name').textContent, + 'channel2', + "The name of the 2nd chatWindow should be 'channel2' (channel with ID 2)" + ); + assert.strictEqual( + chatWindows[1].querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The 2nd chatWindow composer must have focus (channel with ID 2)" + ); + + await afterNextRender(() => + triggerEvent( + chatWindows[1].querySelector('.o_ComposerTextInput_textarea'), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows should still be opened"); + assert.strictEqual( + chatWindows[0].querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The 1st chatWindow composer must have focus (channel with ID 1)" + ); +}); + +QUnit.test('chat window: TAB cycle with 3 open chat windows [REQUIRE FOCUS]', async function (assert) { + /** + * InnerWith computation uses following info: + * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method) + * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1920px + * + * Enough space for 3 visible chat windows: + * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 1920 + */ + assert.expect(6); + + this.data['mail.channel'].records.push( + { + is_minimized: true, + is_pinned: true, + state: 'open', + }, + { + is_minimized: true, + is_pinned: true, + state: 'open', + }, + { + is_minimized: true, + is_pinned: true, + state: 'open', + } + ); + await this.start({ + env: { + browser: { + innerWidth: 1920, + }, + }, + }); + assert.containsN( + document.body, + '.o_ChatWindow .o_ComposerTextInput_textarea', + 3, + "initialy, 3 chat windows should be present" + ); + assert.containsNone( + document.body, + '.o_ChatWindow.o-folded', + "all 3 chat windows should be open" + ); + + await afterNextRender(() => { + document.querySelector(".o_ChatWindow[data-visible-index='2'] .o_ComposerTextInput_textarea").focus(); + }); + assert.strictEqual( + document.querySelector(".o_ChatWindow[data-visible-index='2'] .o_ComposerTextInput_textarea"), + document.activeElement, + "The chatWindow with visible-index 2 should have the focus" + ); + + await afterNextRender(() => + triggerEvent( + document.querySelector(".o_ChatWindow[data-visible-index='2'] .o_ComposerTextInput_textarea"), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.strictEqual( + document.querySelector(".o_ChatWindow[data-visible-index='1'] .o_ComposerTextInput_textarea"), + document.activeElement, + "after pressing tab on the chatWindow with visible-index 2, the chatWindow with visible-index 1 should have focus" + ); + + await afterNextRender(() => + triggerEvent( + document.querySelector(".o_ChatWindow[data-visible-index='1'] .o_ComposerTextInput_textarea"), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.strictEqual( + document.querySelector(".o_ChatWindow[data-visible-index='0'] .o_ComposerTextInput_textarea"), + document.activeElement, + "after pressing tab on the chat window with visible-index 1, the chatWindow with visible-index 0 should have focus" + ); + + await afterNextRender(() => + triggerEvent( + document.querySelector(".o_ChatWindow[data-visible-index='0'] .o_ComposerTextInput_textarea"), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.strictEqual( + document.querySelector(".o_ChatWindow[data-visible-index='2'] .o_ComposerTextInput_textarea"), + document.activeElement, + "the chatWindow with visible-index 2 should have the focus after pressing tab on the chatWindow with visible-index 0" + ); +}); + +QUnit.test('chat window with a thread: keep scroll position in message list on folded', async function (assert) { + assert.expect(3); + + // channel that is expected to be found in the messaging menu + // with a random unique id, needed to link messages + this.data['mail.channel'].records.push({ id: 20 }); + for (let i = 0; i < 10; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + }); + } + await this.start(); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_NotificationList_preview').click(), + message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); + // Set a scroll position to chat window + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + document.querySelector(`.o_ThreadView_messageList`).scrollTop = 142; + }, + message: "should wait until channel 20 scrolled to 142 after setting this value manually", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === 142 + ); + }, + }); + assert.strictEqual( + document.querySelector(`.o_ThreadView_messageList`).scrollTop, + 142, + "verify chat window initial scrollTop" + ); + + // fold chat window + await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + assert.containsNone( + document.body, + ".o_ThreadView", + "chat window should be folded so no ThreadView should be present" + ); + + // unfold chat window + await afterNextRender(() => this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_ChatWindow_header').click(), + message: "should wait until channel 20 restored its scroll position to 142", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === 142 + + ); + }, + })); + assert.strictEqual( + document.querySelector(`.o_ThreadView_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same when chat window is unfolded" + ); +}); + +QUnit.test('chat window should scroll to the newly posted message just after posting it', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ + id: 20, + is_minimized: true, + state: 'open', + }); + for (let i = 0; i < 10; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + }); + } + await this.start(); + + // Set content of the composer of the chat window + await afterNextRender(() => { + document.querySelector('.o_ComposerTextInput_textarea').focus(); + document.execCommand('insertText', false, 'WOLOLO'); + }); + // Send a new message in the chatwindow to trigger the scroll + await afterNextRender(() => + triggerEvent( + document.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'), + 'keydown', + { key: 'Enter' }, + ) + ); + const messageList = document.querySelector('.o_MessageList'); + assert.strictEqual( + messageList.scrollHeight - messageList.scrollTop, + messageList.clientHeight, + "chat window should scroll to the newly posted message just after posting it" + ); +}); + +QUnit.test('chat window: post message on non-mailing channel with "CTRL-Enter" keyboard shortcut for small screen size', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ + id: 20, + is_minimized: true, + mass_mailing: false, + }); + await this.start({ + env: { + device: { + isMobile: true, // here isMobile is used for the small screen size, not actually for the mobile devices + }, + }, + }); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + // insert some HTML in editable + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + await afterNextRender(() => { + const kevt = new window.KeyboardEvent('keydown', { ctrlKey: true, key: "Enter" }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + }); + assert.containsOnce( + document.body, + '.o_Message', + "should now have single message in channel after posting message from pressing 'CTRL-Enter' in text input of composer for small screen" + ); +}); + +QUnit.test('[technical] chat window: composer state conservation on toggle home menu when folded', async function (assert) { + // technical as show/hide home menu simulation are involved and home menu implementation + // have side-effects on DOM that may make chat window components not work + assert.expect(6); + + // channel that is expected to be found in the messaging menu + // only its existence matters, data are irrelevant + this.data['mail.channel'].records.push({}); + await this.start(); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + // Set content of the composer of the chat window + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, 'XDU for the win !'); + }); + // Set attachments of the composer + const files = [ + await createFile({ + name: 'text state conservation on toggle home menu.txt', + content: 'hello, world', + contentType: 'text/plain', + }), + await createFile({ + name: 'text2 state conservation on toggle home menu.txt', + content: 'hello, xdu is da best man', + contentType: 'text/plain', + }) + ]; + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + files + ) + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "XDU for the win !", + "verify chat window composer initial html input" + ); + assert.containsN( + document.body, + '.o_Composer .o_Attachment', + 2, + "verify chat window composer initial attachment count" + ); + + // fold chat window + await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + await this.hideHomeMenu(); + // unfold chat window + await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "XDU for the win !", + "Chat window composer should still have the same input after hiding home menu" + ); + assert.containsN( + document.body, + '.o_Composer .o_Attachment', + 2, + "Chat window composer should have 2 attachments after hiding home menu" + ); + + // fold chat window + await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + await this.showHomeMenu(); + // unfold chat window + await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "XDU for the win !", + "chat window composer should still have the same input after showing home menu" + ); + assert.containsN( + document.body, + '.o_Composer .o_Attachment', + 2, + "Chat window composer should have 2 attachments after showing home menu" + ); +}); + +QUnit.test('[technical] chat window with a thread: keep scroll position in message list on toggle home menu when folded', async function (assert) { + // technical as show/hide home menu simulation are involved and home menu implementation + // have side-effects on DOM that may make chat window components not work + assert.expect(2); + + // channel that is expected to be found in the messaging menu + // with random unique id, needed to link messages + this.data['mail.channel'].records.push({ id: 20 }); + for (let i = 0; i < 10; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + }); + } + await this.start(); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_NotificationList_preview').click(), + message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); + // Set a scroll position to chat window + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector(`.o_ThreadView_messageList`).scrollTop = 142, + message: "should wait until channel 20 scrolled to 142 after setting this value manually", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === 142 + ); + }, + }); + // fold chat window + await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + await this.hideHomeMenu(); + // unfold chat window + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_ChatWindow_header').click(), + message: "should wait until channel 20 restored its scroll to 142 after unfolding it", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === 142 + ); + }, + }); + assert.strictEqual( + document.querySelector(`.o_ThreadView_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same after home menu is hidden" + ); + + // fold chat window + await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + // Show home menu + await this.showHomeMenu(); + // unfold chat window + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_ChatWindow_header').click(), + message: "should wait until channel 20 restored its scroll position to the last saved value (142)", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === 142 + ); + }, + }); + assert.strictEqual( + document.querySelector(`.o_ThreadView_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same after home menu is shown" + ); +}); + +QUnit.test('chat window does not fetch messages if hidden', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: 900px) + * (others: @see `mail/static/src/models/chat_window_manager/chat_window_manager.js:visual`) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1080px + * + * Enough space for 2 visible chat windows, and one hidden chat window: + * 3 visible chat windows: + * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 > 900 + * 2 visible chat windows + hidden menu: + * 10 + 325 + 5 + 325 + 10 + 200 + 5 = 875 < 900 + */ + assert.expect(14); + + // 3 channels are expected to be found in the messaging menu, each with a + // random unique id that will be referenced in the test + this.data['mail.channel'].records = [ + { + id: 10, + is_minimized: true, + name: "Channel #10", + state: 'open', + }, + { + id: 11, + is_minimized: true, + name: "Channel #11", + state: 'open', + }, + { + id: 12, + is_minimized: true, + name: "Channel #12", + state: 'open', + }, + ]; + await this.start({ + env: { + browser: { + innerWidth: 900, + }, + }, + mockRPC(route, args) { + if (args.method === 'message_fetch') { + // domain should be like [['channel_id', 'in', [X]]] with X the channel id + const channel_ids = args.kwargs.domain[0][2]; + assert.strictEqual(channel_ids.length, 1, "messages should be fetched channel per channel"); + assert.step(`rpc:message_fetch:${channel_ids[0]}`); + } + return this._super(...arguments); + }, + }); + + assert.containsN( + document.body, + '.o_ChatWindow', + 2, + "2 chat windows should be visible" + ); + assert.containsNone( + document.body, + `.o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }).localId + }"]`, + "chat window for Channel #12 should be hidden" + ); + assert.containsOnce( + document.body, + '.o_ChatWindowHiddenMenu', + "chat window hidden menu should be displayed" + ); + assert.verifySteps( + ['rpc:message_fetch:10', 'rpc:message_fetch:11'], + "messages should be fetched for the two visible chat windows" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatWindowHiddenMenu_dropdownToggle').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindowHiddenMenu_chatWindowHeader', + "1 hidden chat window should be listed in hidden menu" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatWindowHiddenMenu_chatWindowHeader').click() + ); + assert.containsN( + document.body, + '.o_ChatWindow', + 2, + "2 chat windows should still be visible" + ); + assert.containsOnce( + document.body, + `.o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }).localId + }"]`, + "chat window for Channel #12 should now be visible" + ); + assert.verifySteps( + ['rpc:message_fetch:12'], + "messages should now be fetched for Channel #12" + ); +}); + +QUnit.test('new message separator is shown in a chat window of a chat on receiving new message if there is a history of conversation', async function (assert) { + assert.expect(3); + + this.data['res.partner'].records.push({ id: 10, name: "Demo" }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 10, + }); + this.data['mail.channel'].records = [ + { + channel_type: "chat", + id: 10, + is_minimized: true, + is_pinned: false, + members: [this.data.currentPartnerId, 10], + uuid: 'channel-10-uuid', + }, + ]; + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [10], + model: 'mail.channel', + res_id: 10, + }); + await this.start(); + + // simulate receiving a message + await afterNextRender(async () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hu", + uuid: 'channel-10-uuid', + }, + })); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "a chat window should be visible after receiving a new message from a chat" + ); + assert.containsN( + document.body, + '.o_Message', + 2, + "chat window should have 2 messages" + ); + assert.containsOnce( + document.body, + '.o_MessageList_separatorNewMessages', + "should display 'new messages' separator in the conversation, from reception of new messages" + ); +}); + +QUnit.test('new message separator is not shown in a chat window of a chat on receiving new message if there is no history of conversation', async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ id: 10, name: "Demo" }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 10, + }); + this.data['mail.channel'].records = [{ + channel_type: "chat", + id: 10, + members: [this.data.currentPartnerId, 10], + uuid: 'channel-10-uuid', + }]; + await this.start(); + + // simulate receiving a message + await afterNextRender(async () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hu", + uuid: 'channel-10-uuid', + }, + })); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "should not display 'new messages' separator in the conversation of a chat on receiving new message if there is no history of conversation" + ); +}); + +QUnit.test('focusing a chat window of a chat should make new message separator disappear [REQUIRE FOCUS]', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ id: 10, name: "Demo" }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 10, + }); + this.data['mail.channel'].records.push( + { + channel_type: "chat", + id: 10, + is_minimized: true, + is_pinned: false, + members: [this.data.currentPartnerId, 10], + message_unread_counter: 0, + uuid: 'channel-10-uuid', + }, + ); + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [10], + model: 'mail.channel', + res_id: 10, + }); + await this.start(); + + // simulate receiving a message + await afterNextRender(() => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hu", + uuid: 'channel-10-uuid', + }, + })); + assert.containsOnce( + document.body, + '.o_MessageList_separatorNewMessages', + "should display 'new messages' separator in the conversation, from reception of new messages" + ); + + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => document.querySelector('.o_ComposerTextInput_textarea').focus(), + message: "should wait until last seen by current partner message id changed", + predicate: ({ thread }) => { + return ( + thread.id === 10 && + thread.model === 'mail.channel' + ); + }, + })); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "new message separator should no longer be shown, after focus on composer text input of chat window" + ); +}); + +QUnit.test('chat window should remain folded when new message is received', async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ id: 10, name: "Demo" }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 10, + }); + this.data['mail.channel'].records = [ + { + channel_type: "chat", + id: 10, + is_minimized: true, + is_pinned: false, + members: [this.data.currentPartnerId, 10], + state: 'folded', + uuid: 'channel-10-uuid', + }, + ]; + + await this.start(); + // simulate receiving a new message + await afterNextRender(async () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "New Message 2", + uuid: 'channel-10-uuid', + }, + })); + assert.hasClass( + document.querySelector(`.o_ChatWindow`), + 'o-folded', + "chat window should remain folded" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/chatter/chatter.js b/addons/mail/static/src/components/chatter/chatter.js new file mode 100644 index 00000000..3f6ca7dc --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.js @@ -0,0 +1,150 @@ +odoo.define('mail/static/src/components/chatter/chatter.js', function (require) { +'use strict'; + +const components = { + ActivityBox: require('mail/static/src/components/activity_box/activity_box.js'), + AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'), + ChatterTopbar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'), + Composer: require('mail/static/src/components/composer/composer.js'), + ThreadView: require('mail/static/src/components/thread_view/thread_view.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Chatter extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + const thread = chatter ? chatter.thread : undefined; + let attachments = []; + if (thread) { + attachments = thread.allAttachments; + } + return { + attachments: attachments.map(attachment => attachment.__state), + chatter: chatter ? chatter.__state : undefined, + composer: thread && thread.composer, + thread, + threadActivitiesLength: thread && thread.activities.length, + }; + }, { + compareDepth: { + attachments: 1, + }, + }); + useUpdate({ func: () => this._update() }); + /** + * Reference of the composer. Useful to focus it. + */ + this._composerRef = useRef('composer'); + /** + * Reference of the scroll Panel (Real scroll element). Useful to pass the Scroll element to + * child component to handle proper scrollable element. + */ + this._scrollPanelRef = useRef('scrollPanel'); + /** + * Reference of the message list. Useful to trigger the scroll event on it. + */ + this._threadRef = useRef('thread'); + this.getScrollableElement = this.getScrollableElement.bind(this); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + /** + * @returns {Element|undefined} Scrollable Element + */ + getScrollableElement() { + if (!this._scrollPanelRef.el) { + return; + } + return this._scrollPanelRef.el; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _notifyRendered() { + this.trigger('o-chatter-rendered', { + attachments: this.chatter.thread.allAttachments, + thread: this.chatter.thread.localId, + }); + } + + /** + * @private + */ + _update() { + if (!this.chatter) { + return; + } + if (this.chatter.thread) { + this._notifyRendered(); + } + if (this.chatter.isDoFocus) { + this.chatter.update({ isDoFocus: false }); + const composer = this._composerRef.comp; + if (composer) { + composer.focus(); + } + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onComposerMessagePosted() { + this.chatter.update({ isComposerVisible: false }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onScrollPanelScroll(ev) { + if (!this._threadRef.comp) { + return; + } + this._threadRef.comp.onScroll(ev); + } + +} + +Object.assign(Chatter, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.Chatter', +}); + +return Chatter; + +}); diff --git a/addons/mail/static/src/components/chatter/chatter.scss b/addons/mail/static/src/components/chatter/chatter.scss new file mode 100644 index 00000000..d722e03b --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.scss @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Chatter { + position: relative; + display: flex; + flex: 1 1 auto; + flex-direction: column; + width: map-get($sizes, 100); +} + +.o_Chatter_composer { + border-bottom: $border-width solid; + + &.o-bordered { + border-left: $border-width solid; + border-right: $border-width solid; + } +} + +.o_Chatter_scrollPanel { + overflow-y: auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Chatter { + background-color: white; + border-color: $border-color; +} + +.o_Chatter_composer { + border-bottom-color: $border-color; + + &.o-bordered { + border-left-color: $border-color; + border-right-color: $border-color; + } +} diff --git a/addons/mail/static/src/components/chatter/chatter.xml b/addons/mail/static/src/components/chatter/chatter.xml new file mode 100644 index 00000000..d9cf20b4 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Chatter" owl="1"> + <div class="o_Chatter"> + <t t-if="chatter"> + <div class="o_Chatter_fixedPanel"> + <ChatterTopbar + class="o_Chatter_topbar" + chatterLocalId="chatter.localId" + /> + <t t-if="chatter.threadView and chatter.isComposerVisible"> + <Composer + class="o_Chatter_composer" + t-att-class="{ 'o-bordered': chatter.hasExternalBorder }" + composerLocalId="chatter.thread.composer.localId" + hasFollowers="true" + hasMentionSuggestionsBelowPosition="true" + isCompact="false" + isExpandable="true" + textInputSendShortcuts="['ctrl-enter', 'meta-enter']" + t-on-o-message-posted="_onComposerMessagePosted" + t-ref="composer" + /> + </t> + </div> + <div class="o_Chatter_scrollPanel" t-on-scroll="_onScrollPanelScroll" t-ref="scrollPanel"> + <t t-if="chatter.isAttachmentBoxVisible"> + <AttachmentBox + class="o_Chatter_attachmentBox" + threadLocalId="chatter.thread.localId" + /> + </t> + <t t-if="chatter.thread and chatter.hasActivities and chatter.thread.activities.length > 0"> + <ActivityBox + class="o_Chatter_activityBox" + chatterLocalId="chatter.localId" + /> + </t> + <t t-if="chatter.threadView"> + <ThreadView + class="o_Chatter_thread" + getScrollableElement="getScrollableElement" + hasComposer="false" + hasScrollAdjust="chatter.hasMessageListScrollAdjust" + order="'desc'" + threadViewLocalId="chatter.threadView.localId" + t-ref="thread" + /> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter/chatter_suggested_recipient_tests.js b/addons/mail/static/src/components/chatter/chatter_suggested_recipient_tests.js new file mode 100644 index 00000000..9cb57d83 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter_suggested_recipient_tests.js @@ -0,0 +1,420 @@ +odoo.define('mail/static/src/components/chatter/chatter_suggested_recipient_tests', function (require) { +'use strict'; + +const components = { + Chatter: require('mail/static/src/components/chatter/chatter.js'), + Composer: require('mail/static/src/components/composer/composer.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chatter', {}, function () { +QUnit.module('chatter_suggested_recipients_tests.js', { + beforeEach() { + beforeEach(this); + + this.createChatterComponent = async ({ chatter }, otherProps) => { + const props = Object.assign({ chatterLocalId: chatter.localId }, otherProps); + await createRootComponent(this, components.Chatter, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test("suggest recipient on 'Send message' composer", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.fake'].records.push({ + id: 10, + email_cc: "john@test.be", + partner_ids: [100], + }); + await this.start (); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestedRecipientList', + "Should display a list of suggested recipients after opening the composer from 'Send message' button" + ); +}); + +QUnit.test("with 3 or less suggested recipients: no 'show more' button", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.fake'].records.push({ + id: 10, + email_cc: "john@test.be", + partner_ids: [100], + }); + await this.start (); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.containsNone( + document.body, + '.o_ComposerSuggestedRecipientList_showMore', + "should not display 'show more' button with 3 or less suggested recipients" + ); +}); + +QUnit.test("display reason for suggested recipient on mouse over", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.fake'].records.push({ + id: 10, + partner_ids: [100], + }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + const partnerTitle = document.querySelector('.o_ComposerSuggestedRecipient[data-partner-id="100"]').getAttribute('title'); + assert.strictEqual( + partnerTitle, + "Add as recipient and follower (reason: Email partner)", + "must display reason for suggested recipient on mouse over", + ); +}); + +QUnit.test("suggested recipient without partner are unchecked by default", async function (assert) { + assert.expect(1); + + this.data['res.fake'].records.push({ + id: 10, + email_cc: "john@test.be", + }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + const checkboxUnchecked = document.querySelector('.o_ComposerSuggestedRecipient:not([data-partner-id]) input[type=checkbox]'); + assert.notOk( + checkboxUnchecked.checked, + "suggested recipient without partner must be unchecked by default", + ); +}); + +QUnit.test("suggested recipient with partner are checked by default", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.fake'].records.push({ + id: 10, + partner_ids: [100], + }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + const checkboxChecked = document.querySelector('.o_ComposerSuggestedRecipient[data-partner-id="100"] input[type=checkbox]'); + assert.ok( + checkboxChecked.checked, + "suggested recipient with partner must be checked by default", + ); +}); + +QUnit.test("more than 3 suggested recipients: display only 3 and 'show more' button", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.partner'].records.push({ + display_name: "Jack Jone", + email: "jack@jone.be", + id: 1000, + }); + this.data['res.partner'].records.push({ + display_name: "jolly Roger", + email: "Roger@skullflag.com", + id: 1001, + }); + this.data['res.partner'].records.push({ + display_name: "jack sparrow", + email: "jsparrow@blackpearl.bb", + id: 1002, + }); + this.data['res.fake'].records.push({ + id: 10, + partner_ids: [100, 1000, 1001, 1002], + }); + await this.start (); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestedRecipientList_showMore', + "more than 3 suggested recipients display 'show more' button" + ); +}); + +QUnit.test("more than 3 suggested recipients: show all of them on click 'show more' button", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.partner'].records.push({ + display_name: "Jack Jone", + email: "jack@jone.be", + id: 1000, + }); + this.data['res.partner'].records.push({ + display_name: "jolly Roger", + email: "Roger@skullflag.com", + id: 1001, + }); + this.data['res.partner'].records.push({ + display_name: "jack sparrow", + email: "jsparrow@blackpearl.bb", + id: 1002, + }); + this.data['res.fake'].records.push({ + id: 10, + partner_ids: [100, 1000, 1001, 1002], + }); + await this.start (); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_ComposerSuggestedRecipientList_showMore`).click() + ); + assert.containsN( + document.body, + '.o_ComposerSuggestedRecipient', + 4, + "more than 3 suggested recipients: show all of them on click 'show more' button" + ); +}); + +QUnit.test("more than 3 suggested recipients -> click 'show more' -> 'show less' button", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.partner'].records.push({ + display_name: "Jack Jone", + email: "jack@jone.be", + id: 1000, + }); + this.data['res.partner'].records.push({ + display_name: "jolly Roger", + email: "Roger@skullflag.com", + id: 1001, + }); + this.data['res.partner'].records.push({ + display_name: "jack sparrow", + email: "jsparrow@blackpearl.bb", + id: 1002, + }); + this.data['res.fake'].records.push({ + id: 10, + partner_ids: [100, 1000, 1001, 1002], + }); + await this.start (); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_ComposerSuggestedRecipientList_showMore`).click() + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestedRecipientList_showLess', + "more than 3 suggested recipients -> click 'show more' -> 'show less' button" + ); +}); + +QUnit.test("suggested recipients list display 3 suggested recipient and 'show more' button when 'show less' button is clicked", async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.partner'].records.push({ + display_name: "Jack Jone", + email: "jack@jone.be", + id: 1000, + }); + this.data['res.partner'].records.push({ + display_name: "jolly Roger", + email: "Roger@skullflag.com", + id: 1001, + }); + this.data['res.partner'].records.push({ + display_name: "jack sparrow", + email: "jsparrow@blackpearl.bb", + id: 1002, + }); + this.data['res.fake'].records.push({ + id: 10, + partner_ids: [100, 1000, 1001, 1002], + }); + await this.start (); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_ComposerSuggestedRecipientList_showMore`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_ComposerSuggestedRecipientList_showLess`).click() + ); + assert.containsN( + document.body, + '.o_ComposerSuggestedRecipient', + 3, + "suggested recipient list should display 3 suggested recipients after clicking on 'show less'." + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestedRecipientList_showMore', + "suggested recipient list should containt a 'show More' button after clicking on 'show less'." + ); +}); + +QUnit.test("suggested recipients should not be notified when posting an internal note", async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ + display_name: "John Jane", + email: "john@jane.be", + id: 100, + }); + this.data['res.fake'].records.push({ + id: 10, + partner_ids: [100], + }); + await this.start({ + async mockRPC(route, args) { + if (args.model === 'res.fake' && args.method === 'message_post') { + assert.strictEqual( + args.kwargs.partner_ids.length, + 0, + "message_post should not contain suggested recipients when posting an internal note" + ); + } + return this._super(...arguments); + }, + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 10, + threadModel: 'res.fake', + }); + await this.createChatterComponent({ chatter }); + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => document.execCommand('insertText', false, "Dummy Message")); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/chatter/chatter_tests.js b/addons/mail/static/src/components/chatter/chatter_tests.js new file mode 100644 index 00000000..15163e85 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter_tests.js @@ -0,0 +1,469 @@ +odoo.define('mail/static/src/components/chatter/chatter_tests', function (require) { +'use strict'; + +const components = { + Chatter: require('mail/static/src/components/chatter/chatter.js'), + Composer: require('mail/static/src/components/composer/composer.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chatter', {}, function () { +QUnit.module('chatter_tests.js', { + beforeEach() { + beforeEach(this); + + this.createChatterComponent = async ({ chatter }, otherProps) => { + const props = Object.assign({ chatterLocalId: chatter.localId }, otherProps); + await createRootComponent(this, components.Chatter, { + props, + target: this.widget.el, + }); + }; + + this.createComposerComponent = async (composer, otherProps) => { + const props = Object.assign({ composerLocalId: composer.localId }, otherProps); + await createRootComponent(this, components.Composer, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('base rendering when chatter has no attachment', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push({ id: 100 }); + for (let i = 0; i < 60; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + model: 'res.partner', + res_id: 100, + }); + } + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_thread`).length, + 1, + "should have a thread in the chatter" + ); + assert.strictEqual( + document.querySelector(`.o_Chatter_thread`).dataset.threadLocalId, + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'res.partner', + }).localId, + "thread should have the right thread local id" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "the first 30 messages of thread should be loaded" + ); +}); + +QUnit.test('base rendering when chatter has no record', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_thread`).length, + 1, + "should have a thread in the chatter" + ); + assert.ok( + chatter.thread.isTemporary, + "thread should have a temporary thread linked to chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 1, + "should have a message" + ); + assert.strictEqual( + document.querySelector(`.o_Message_content`).textContent, + "Creating a new record...", + "should have the 'Creating a new record ...' message" + ); + assert.containsNone( + document.body, + '.o_MessageList_loadMore', + "should not have the 'load more' button" + ); +}); + +QUnit.test('base rendering when chatter has attachments', async function (assert) { + assert.expect(3); + + this.data['res.partner'].records.push({ id: 100 }); + this.data['ir.attachment'].records.push( + { + mimetype: 'text/plain', + name: 'Blah.txt', + res_id: 100, + res_model: 'res.partner', + }, + { + mimetype: 'text/plain', + name: 'Blu.txt', + res_id: 100, + res_model: 'res.partner', + } + ); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); +}); + +QUnit.test('show attachment box', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push({ id: 100 }); + this.data['ir.attachment'].records.push( + { + mimetype: 'text/plain', + name: 'Blah.txt', + res_id: 100, + res_model: 'res.partner', + }, + { + mimetype: 'text/plain', + name: 'Blu.txt', + res_id: 100, + res_model: 'res.partner', + } + ); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 1, + "should have an attachment box in the chatter" + ); +}); + +QUnit.test('composer show/hide on log note/send message [REQUIRE FOCUS]', async function (assert) { + assert.expect(10); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length, + 1, + "should have a send message button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length, + 1, + "should have a log note button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should not have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should have a composer" + ); + assert.hasClass( + document.querySelector('.o_Chatter_composer'), + 'o-focused', + "composer 'send message' in chatter should have focus just after being displayed" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should still have a composer" + ); + assert.hasClass( + document.querySelector('.o_Chatter_composer'), + 'o-focused', + "composer 'log note' in chatter should have focus just after being displayed" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should have no composer anymore" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should have no composer anymore" + ); +}); + +QUnit.test('should not display user notification messages in chatter', async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ id: 100 }); + this.data['mail.message'].records.push({ + id: 102, + message_type: 'user_notification', + model: 'res.partner', + res_id: 100, + }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + + assert.containsNone( + document.body, + '.o_Message', + "should display no messages" + ); +}); + +QUnit.test('post message with "CTRL-Enter" keyboard shortcut', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.containsNone( + document.body, + '.o_Message', + "should not have any message initially in chatter" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatterTopbar_buttonSendMessage').click() + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + await afterNextRender(() => { + const kevt = new window.KeyboardEvent('keydown', { ctrlKey: true, key: "Enter" }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + }); + assert.containsOnce( + document.body, + '.o_Message', + "should now have single message in chatter after posting message from pressing 'CTRL-Enter' in text input of composer" + ); +}); + +QUnit.test('post message with "META-Enter" keyboard shortcut', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.containsNone( + document.body, + '.o_Message', + "should not have any message initially in chatter" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatterTopbar_buttonSendMessage').click() + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + await afterNextRender(() => { + const kevt = new window.KeyboardEvent('keydown', { key: "Enter", metaKey: true }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + }); + assert.containsOnce( + document.body, + '.o_Message', + "should now have single message in channel after posting message from pressing 'META-Enter' in text input of composer" + ); +}); + +QUnit.test('do not post message with "Enter" keyboard shortcut', async function (assert) { + // Note that test doesn't assert Enter makes a newline, because this + // default browser cannot be simulated with just dispatching + // programmatically crafted events... + assert.expect(2); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.containsNone( + document.body, + '.o_Message', + "should not have any message initially in chatter" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatterTopbar_buttonSendMessage').click() + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + const kevt = new window.KeyboardEvent('keydown', { key: "Enter" }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + await nextAnimationFrame(); + assert.containsNone( + document.body, + '.o_Message', + "should still not have any message in mailing channel after pressing 'Enter' in text input of composer" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.js b/addons/mail/static/src/components/chatter_container/chatter_container.js new file mode 100644 index 00000000..2b186e62 --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.js @@ -0,0 +1,139 @@ +odoo.define('mail/static/src/components/chatter_container/chatter_container.js', function (require) { +'use strict'; + +const components = { + Chatter: require('mail/static/src/components/chatter/chatter.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); + +const { Component } = owl; + +/** + * This component abstracts chatter component to its parent, so that it can be + * mounted and receive chatter data even when a chatter component cannot be + * created. Indeed, in order to create a chatter component, we must create + * a chatter record, the latter requiring messaging to be initialized. The view + * may attempt to create a chatter before messaging has been initialized, so + * this component delays the mounting of chatter until it becomes initialized. + */ +class ChatterContainer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.chatter = undefined; + this._wasMessagingInitialized = false; + useShouldUpdateBasedOnProps(); + useStore(props => { + const isMessagingInitialized = this.env.isMessagingInitialized(); + // Delay creation of chatter record until messaging is initialized. + // Ideally should observe models directly to detect change instead + // of using `useStore`. + if (!this._wasMessagingInitialized && isMessagingInitialized) { + this._wasMessagingInitialized = true; + this._insertFromProps(props); + } + return { chatter: this.chatter }; + }); + useUpdate({ func: () => this._update() }); + } + + /** + * @override + */ + willUpdateProps(nextProps) { + if (this.env.isMessagingInitialized()) { + this._insertFromProps(nextProps); + } + return super.willUpdateProps(...arguments); + } + + /** + * @override + */ + destroy() { + super.destroy(); + if (this.chatter) { + this.chatter.delete(); + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _insertFromProps(props) { + const values = Object.assign({}, props); + if (values.threadId === undefined) { + values.threadId = clear(); + } + if (!this.chatter) { + this.chatter = this.env.models['mail.chatter'].create(values); + } else { + this.chatter.update(values); + } + } + + /** + * @private + */ + _update() { + if (this.chatter) { + this.chatter.refresh(); + } + } + +} + +Object.assign(ChatterContainer, { + components, + props: { + hasActivities: { + type: Boolean, + optional: true, + }, + hasExternalBorder: { + type: Boolean, + optional: true, + }, + hasFollowers: { + type: Boolean, + optional: true, + }, + hasMessageList: { + type: Boolean, + optional: true, + }, + hasMessageListScrollAdjust: { + type: Boolean, + optional: true, + }, + hasTopbarCloseButton: { + type: Boolean, + optional: true, + }, + isAttachmentBoxVisibleInitially: { + type: Boolean, + optional: true, + }, + threadId: { + type: Number, + optional: true, + }, + threadModel: String, + }, + template: 'mail.ChatterContainer', +}); + + +return ChatterContainer; + +}); diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.scss b/addons/mail/static/src/components/chatter_container/chatter_container.scss new file mode 100644 index 00000000..8cd51580 --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.scss @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatterContainer { + display: flex; + flex: 1 1 auto; + width: map-get($sizes, 100); +} + +.o_ChatterContainer_noChatter { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.o_ChatterContainer_noChatterIcon { + margin-right: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.xml b/addons/mail/static/src/components/chatter_container/chatter_container.xml new file mode 100644 index 00000000..c1d8d220 --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatterContainer" owl="1"> + <div class="o_ChatterContainer"> + <t t-if="chatter"> + <Chatter chatterLocalId="chatter.localId"/> + </t> + <t t-else=""> + <div class="o_ChatterContainer_noChatter"><i class="o_ChatterContainer_noChatterIcon fa fa-spinner fa-spin"/>Please wait...</div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js new file mode 100644 index 00000000..41d2a461 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js @@ -0,0 +1,137 @@ +odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar.js', function (require) { +'use strict'; + +const components = { + FollowButton: require('mail/static/src/components/follow_button/follow_button.js'), + FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatterTopbar extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + const thread = chatter ? chatter.thread : undefined; + const threadAttachments = thread ? thread.allAttachments : []; + return { + areThreadAttachmentsLoaded: thread && thread.areAttachmentsLoaded, + chatter: chatter ? chatter.__state : undefined, + composerIsLog: chatter && chatter.composer && chatter.composer.isLog, + threadAttachmentsAmount: threadAttachments.length, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAttachments(ev) { + this.chatter.update({ + isAttachmentBoxVisible: !this.chatter.isAttachmentBoxVisible, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + this.trigger('o-close-chatter'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickLogNote(ev) { + if (!this.chatter.composer) { + return; + } + if (this.chatter.isComposerVisible && this.chatter.composer.isLog) { + this.chatter.update({ isComposerVisible: false }); + } else { + this.chatter.showLogNote(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickScheduleActivity(ev) { + const action = { + type: 'ir.actions.act_window', + name: this.env._t("Schedule Activity"), + res_model: 'mail.activity', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.chatter.thread.id, + default_res_model: this.chatter.thread.model, + }, + res_id: false, + }; + return this.env.bus.trigger('do-action', { + action, + options: { + on_close: () => { + this.trigger('reload', { keepChanges: true }); + }, + }, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSendMessage(ev) { + if (!this.chatter.composer) { + return; + } + if (this.chatter.isComposerVisible && !this.chatter.composer.isLog) { + this.chatter.update({ isComposerVisible: false }); + } else { + this.chatter.showSendMessage(); + } + } + +} + +Object.assign(ChatterTopbar, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.ChatterTopbar', +}); + +return ChatterTopbar; + +}); diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss new file mode 100644 index 00000000..062e5219 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss @@ -0,0 +1,106 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatterTopbar { + display: flex; + flex-direction: row; + justify-content: space-between; + // We need the +1 to handle the status bar border-bottom. + // The var is called `$o-statusbar-height`, but is used on button, therefore + // doesn't include the border-bottom. + // We use min-height to allow multiples buttons lines on mobile. + min-height: $o-statusbar-height + 1; +} + +.o_ChatterTopbar_actions { + border-bottom: $border-width solid; + display: flex; + flex: 1; + flex-direction: row; + flex-wrap: wrap-reverse; // reverse to ensure send buttons are directly above composer +} + +.o_ChatterTopbar_button { + margin-bottom: -$border-width; /* Needed to allow "overriding" of the bottom border */ +} + +.o_ChatterTopbar_buttonAttachmentsCountLoader { + margin-left: 2px; +} + +.o_ChatterTopbar_buttonCount { + padding-left: 0.25rem; +} + +.o_ChatterTopbar_buttonClose { + display: flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 34px; + height: 34px; +} + +.o_ChatterTopbar_followerListMenu { + display: flex; +} + +.o_ChatterTopbar_rightSection { + display: flex; + flex: 1 0 auto; + justify-content: flex-end; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatterTopbar_actions { + border-color: transparent; + + &.o-has-active-button { + border-color: $border-color; + } +} + +.o_ChatterTopbar_button { + border-radius: 0; + + &:hover { + background-color: gray('300'); + } + + &.o-active { + color: $o-brand-odoo; + background-color: lighten(gray('300'), 7%); + border-right-color: $border-color; + + &:not(:first-of-type), + &:first-of-type.o-bordered { + border-left-color: $border-color; + } + + &.o-bordered { + border-top-color: $border-color; + } + + &:hover { + background-color: gray('300'); + color: $link-hover-color; + } + } +} + +.o_ChatterTopbar_buttonClose { + border-radius: 0 0 10px 10px; + font-size: $font-size-lg; + background-color: gray('700'); + color: gray('100'); + cursor: pointer; + + &:hover { + background-color: gray('600'); + color: $white; + } +} diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml new file mode 100644 index 00000000..5d4029e6 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatterTopbar" owl="1"> + <div class="o_ChatterTopbar"> + <t t-if="chatter"> + <div class="o_ChatterTopbar_actions" t-att-class="{'o-has-active-button': chatter.isComposerVisible }"> + <t t-if="chatter.threadView"> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonSendMessage" + type="button" + t-att-class="{ + 'o-active': chatter.isComposerVisible and chatter.composer and !chatter.composer.isLog, + 'o-bordered': chatter.hasExternalBorder, + }" + t-att-disabled="chatter.isDisabled" + t-on-click="_onClickSendMessage" + > + Send message + </button> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonLogNote" + type="button" + t-att-class="{ + 'o-active': chatter.isComposerVisible and chatter.composer and chatter.composer.isLog, + 'o-bordered': chatter.hasExternalBorder, + }" + t-att-disabled="chatter.isDisabled" + t-on-click="_onClickLogNote" + > + Log note + </button> + </t> + <t t-if="chatter.hasActivities"> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonScheduleActivity" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickScheduleActivity"> + <i class="fa fa-clock-o"/> + Schedule activity + </button> + </t> + <div class="o-autogrow"/> + <div class="o_ChatterTopbar_rightSection"> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonAttachments" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickAttachments"> + <i class="fa fa-paperclip"/> + <t t-if="chatter.isDisabled or !chatter.isShowingAttachmentsLoading"> + <span class="o_ChatterTopbar_buttonCount o_ChatterTopbar_buttonAttachmentsCount" t-esc="chatter.thread ? chatter.thread.allAttachments.length : 0"/> + </t> + <t t-else=""> + <i class="o_ChatterTopbar_buttonAttachmentsCountLoader fa fa-spinner fa-spin" aria-label="Attachment counter loading..."/> + </t> + </button> + <t t-if="chatter.hasFollowers and chatter.thread"> + <t t-if="chatter.thread.channel_type !== 'chat'"> + <FollowButton + class="o_ChatterTopbar_button o_ChatterTopbar_followButton" + isDisabled="chatter.isDisabled" + threadLocalId="chatter.thread.localId" + /> + </t> + <FollowerListMenu + class="o_ChatterTopbar_button o_ChatterTopbar_followerListMenu" + isDisabled="chatter.isDisabled" + threadLocalId="chatter.thread.localId" + /> + </t> + </div> + </div> + <t t-if="chatter.hasTopbarCloseButton"> + <div class="o_ChatterTopbar_buttonClose" title="Close" t-on-click="_onClickClose"> + <i class="fa fa-times"/> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js new file mode 100644 index 00000000..3063b389 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js @@ -0,0 +1,730 @@ +odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar_tests.js', function (require) { +'use strict'; + +const components = { + ChatterTopBar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const { makeTestPromise } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chatter_topbar', {}, function () { +QUnit.module('chatter_topbar_tests.js', { + beforeEach() { + beforeEach(this); + + this.createChatterTopbarComponent = async (chatter, otherProps) => { + const props = Object.assign({ chatterLocalId: chatter.localId }, otherProps); + await createRootComponent(this, components.ChatterTopBar, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('base rendering', async function (assert) { + assert.expect(8); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length, + 1, + "should have a send message button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length, + 1, + "should have a log note button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonScheduleActivity`).length, + 1, + "should have a schedule activity button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_followerListMenu`).length, + 1, + "should have a follower menu" + ); +}); + +QUnit.test('base disabled rendering', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled, + "send message button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled, + "log note button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonScheduleActivity`).disabled, + "schedule activity should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled, + "attachments button should be disabled" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '0', + "attachments button counter should be 0" + ); +}); + +QUnit.test('attachment loading is delayed', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start({ + hasTimeControl: true, + loadingBaseDelayDuration: 100, + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + await makeTestPromise(); // simulate long loading + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader yet" + ); + + await afterNextRender(async () => this.env.testUtils.advanceTime(100)); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should now have a loader" + ); +}); + +QUnit.test('attachment counter while loading attachments', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start({ + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + await makeTestPromise(); // simulate long loading + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 0, + "attachments button should not have a counter" + ); +}); + +QUnit.test('attachment counter transition when attachments become loaded)', async function (assert) { + assert.expect(7); + + this.data['res.partner'].records.push({ id: 100 }); + const attachmentPromise = makeTestPromise(); + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route.includes('ir.attachment/search_read')) { + await attachmentPromise; + } + return _super(); + }, + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 0, + "attachments button should not have a counter" + ); + + await afterNextRender(() => attachmentPromise.resolve()); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); +}); + +QUnit.test('attachment counter without attachments', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '0', + 'attachment counter should contain "0"' + ); +}); + +QUnit.test('attachment counter with attachments', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 100 }); + this.data['ir.attachment'].records.push( + { + mimetype: 'text/plain', + name: 'Blah.txt', + res_id: 100, + res_model: 'res.partner', + }, + { + mimetype: 'text/plain', + name: 'Blu.txt', + res_id: 100, + res_model: 'res.partner', + } + ); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '2', + 'attachment counter should contain "2"' + ); +}); + +QUnit.test('composer state conserved when clicking on another topbar button', async function (assert) { + assert.expect(8); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + `.o_ChatterTopbar`, + "should have a chatter topbar" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonSendMessage`, + "should have a send message button in chatter menu" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote`, + "should have a log note button in chatter menu" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonAttachments`, + "should have an attachments button in chatter menu" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click(); + }); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote.o-active`, + "log button should now be active" + ); + assert.containsNone( + document.body, + `.o_ChatterTopbar_buttonSendMessage.o-active`, + "send message button should not be active" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click(); + }); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote.o-active`, + "log button should still be active" + ); + assert.containsNone( + document.body, + `.o_ChatterTopbar_buttonSendMessage.o-active`, + "send message button should still be not active" + ); +}); + +QUnit.test('rendering with multiple partner followers', async function (assert) { + assert.expect(7); + + await this.start(); + this.data['res.partner'].records.push({ + id: 100, + message_follower_ids: [1, 2], + }); + this.data['mail.followers'].records.push( + { + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + channel_id: false, + id: 1, + name: "Jean Michang", + partner_id: 12, + res_id: 100, + res_model: 'res.partner', + }, { + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + channel_id: false, + id: 2, + name: "Eden Hazard", + partner_id: 11, + res_id: 100, + res_model: 'res.partner', + }, + ); + const chatter = this.env.models['mail.chatter'].create({ + followerIds: [1, 2], + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsN( + document.body, + '.o_Follower', + 2, + "exactly two followers should be listed" + ); + assert.containsN( + document.body, + '.o_Follower_name', + 2, + "exactly two follower names should be listed" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[0].textContent.trim(), + "Jean Michang", + "first follower is 'Jean Michang'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[1].textContent.trim(), + "Eden Hazard", + "second follower is 'Eden Hazard'" + ); +}); + +QUnit.test('rendering with multiple channel followers', async function (assert) { + assert.expect(7); + + this.data['res.partner'].records.push({ + id: 100, + message_follower_ids: [1, 2], + }); + await this.start(); + this.data['mail.followers'].records.push( + { + channel_id: 11, + id: 1, + name: "channel numero 5", + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + partner_id: false, + res_id: 100, + res_model: 'res.partner', + }, { + channel_id: 12, + id: 2, + name: "channel armstrong", + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + partner_id: false, + res_id: 100, + res_model: 'res.partner', + }, + ); + const chatter = this.env.models['mail.chatter'].create({ + followerIds: [1, 2], + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsN( + document.body, + '.o_Follower', + 2, + "exactly two followers should be listed" + ); + assert.containsN( + document.body, + '.o_Follower_name', + 2, + "exactly two follower names should be listed" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[0].textContent.trim(), + "channel numero 5", + "first follower is 'channel numero 5'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[1].textContent.trim(), + "channel armstrong", + "second follower is 'channel armstrong'" + ); +}); + +QUnit.test('log note/send message switching', async function (assert) { + assert.expect(8); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonSendMessage', + "should have a 'Send Message' button" + ); + assert.doesNotHaveClass( + document.querySelector('.o_ChatterTopbar_buttonSendMessage'), + 'o-active', + "'Send Message' button should not be active" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonLogNote', + "should have a 'Log Note' button" + ); + assert.doesNotHaveClass( + document.querySelector('.o_ChatterTopbar_buttonLogNote'), + 'o-active', + "'Log Note' button should not be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.hasClass( + document.querySelector('.o_ChatterTopbar_buttonSendMessage'), + 'o-active', + "'Send Message' button should be active" + ); + assert.doesNotHaveClass( + document.querySelector('.o_ChatterTopbar_buttonLogNote'), + 'o-active', + "'Log Note' button should not be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.doesNotHaveClass( + document.querySelector('.o_ChatterTopbar_buttonSendMessage'), + 'o-active', + "'Send Message' button should not be active" + ); + assert.hasClass( + document.querySelector('.o_ChatterTopbar_buttonLogNote'), + 'o-active', + "'Log Note' button should be active" + ); +}); + +QUnit.test('log note toggling', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonLogNote', + "should have a 'Log Note' button" + ); + assert.doesNotHaveClass( + document.querySelector('.o_ChatterTopbar_buttonLogNote'), + 'o-active', + "'Log Note' button should not be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.hasClass( + document.querySelector('.o_ChatterTopbar_buttonLogNote'), + 'o-active', + "'Log Note' button should be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.doesNotHaveClass( + document.querySelector('.o_ChatterTopbar_buttonLogNote'), + 'o-active', + "'Log Note' button should not be active" + ); +}); + +QUnit.test('send message toggling', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonSendMessage', + "should have a 'Send Message' button" + ); + assert.doesNotHaveClass( + document.querySelector('.o_ChatterTopbar_buttonSendMessage'), + 'o-active', + "'Send Message' button should not be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.hasClass( + document.querySelector('.o_ChatterTopbar_buttonSendMessage'), + 'o-active', + "'Send Message' button should be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.doesNotHaveClass( + document.querySelector('.o_ChatterTopbar_buttonSendMessage'), + 'o-active', + "'Send Message' button should not be active" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js new file mode 100644 index 00000000..31654a4f --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.js @@ -0,0 +1,444 @@ +odoo.define('mail/static/src/components/composer/composer.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + ComposerSuggestedRecipientList: require('mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js'), + DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'), + EmojisPopover: require('mail/static/src/components/emojis_popover/emojis_popover.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), + TextInput: require('mail/static/src/components/composer_text_input/composer_text_input.js'), + ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'), +}; +const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js'); +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); +const { + isEventHandled, + markEventHandled, +} = require('mail/static/src/utils/utils.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Composer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.isDropZoneVisible = useDragVisibleDropZone(); + useShouldUpdateBasedOnProps({ + compareDepth: { + textInputSendShortcuts: 1, + }, + }); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + const thread = composer && composer.thread; + return { + composer, + composerAttachments: composer ? composer.attachments : [], + composerCanPostMessage: composer && composer.canPostMessage, + composerHasFocus: composer && composer.hasFocus, + composerIsLog: composer && composer.isLog, + composerSubjectContent: composer && composer.subjectContent, + isDeviceMobile: this.env.messaging.device.isMobile, + thread, + threadChannelType: thread && thread.channel_type, // for livechat override + threadDisplayName: thread && thread.displayName, + threadMassMailing: thread && thread.mass_mailing, + threadModel: thread && thread.model, + threadName: thread && thread.name, + }; + }, { + compareDepth: { + composerAttachments: 1, + }, + }); + useUpdate({ func: () => this._update() }); + /** + * Reference of the emoji popover. Useful to include emoji popover as + * contained "inside" the composer. + */ + this._emojisPopoverRef = useRef('emojisPopover'); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + /** + * Reference of the text input component. + */ + this._textInputRef = useRef('textInput'); + /** + * Reference of the subject input. Useful to set content. + */ + this._subjectRef = useRef('subject'); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + /** + * Returns whether the given node is self or a children of self, including + * the emoji popover. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + // emoji popover is outside but should be considered inside + const emojisPopover = this._emojisPopoverRef.comp; + if (emojisPopover && emojisPopover.contains(node)) { + return true; + } + return this.el.contains(node); + } + + /** + * Get the current partner image URL. + * + * @returns {string} + */ + get currentPartnerAvatar() { + const avatar = this.env.messaging.currentUser + ? this.env.session.url('/web/image', { + field: 'image_128', + id: this.env.messaging.currentUser.id, + model: 'res.users', + }) + : '/web/static/src/img/user_menu_avatar.png'; + return avatar; + } + + /** + * Focus the composer. + */ + focus() { + if (this.env.messaging.device.isMobile) { + this.el.scrollIntoView(); + } + this._textInputRef.comp.focus(); + } + + /** + * Focusout the composer. + */ + focusout() { + this._textInputRef.comp.focusout(); + } + + /** + * Determine whether composer should display a footer. + * + * @returns {boolean} + */ + get hasFooter() { + return ( + this.props.hasThreadTyping || + this.composer.attachments.length > 0 || + !this.props.isCompact + ); + } + + /** + * Determine whether the composer should display a header. + * + * @returns {boolean} + */ + get hasHeader() { + return ( + (this.props.hasThreadName && this.composer.thread) || + (this.props.hasFollowers && !this.composer.isLog) + ); + } + + /** + * Get an object which is passed to FileUploader component to be used when + * creating attachment. + * + * @returns {Object} + */ + get newAttachmentExtraData() { + return { + composers: [['replace', this.composer]], + }; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Post a message in the composer on related thread. + * + * Posting of the message could be aborted if it cannot be posted like if there are attachments + * currently uploading or if there is no text content and no attachments. + * + * @private + */ + async _postMessage() { + if (!this.composer.canPostMessage) { + if (this.composer.hasUploadingAttachment) { + this.env.services['notification'].notify({ + message: this.env._t("Please wait while the file is uploading."), + type: 'warning', + }); + } + return; + } + await this.composer.postMessage(); + // TODO: we might need to remove trigger and use the store to wait for the post rpc to be done + // task-2252858 + this.trigger('o-message-posted'); + } + + /** + * @private + */ + _update() { + if (this.props.isDoFocus) { + this.focus(); + } + if (!this.composer) { + return; + } + if (this._subjectRef.el) { + this._subjectRef.el.value = this.composer.subjectContent; + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on attachment button. + * + * @private + */ + _onClickAddAttachment() { + this._fileUploaderRef.comp.openBrowserFileUploader(); + if (!this.env.device.isMobile) { + this.focus(); + } + } + + /** + * Discards the composer when clicking away. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + if (this.contains(ev.target)) { + return; + } + this.composer.discard(); + } + + /** + * Called when clicking on "expand" button. + * + * @private + */ + _onClickFullComposer() { + this.composer.openFullComposer(); + } + + /** + * Called when clicking on "discard" button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDiscard(ev) { + this.composer.discard(); + } + + /** + * Called when clicking on "send" button. + * + * @private + */ + _onClickSend() { + this._postMessage(); + this.focus(); + } + + /** + * @private + */ + _onComposerSuggestionClicked() { + this.focus(); + } + + /** + * @private + */ + _onComposerTextInputSendShortcut() { + this._postMessage(); + } + + /** + * Called when some files have been dropped in the dropzone. + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {FileList} ev.detail.files + */ + async _onDropZoneFilesDropped(ev) { + ev.stopPropagation(); + await this._fileUploaderRef.comp.uploadFiles(ev.detail.files); + this.isDropZoneVisible.value = false; + } + + /** + * Called when selection an emoji from the emoji popover (from the emoji + * button). + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.unicode + */ + _onEmojiSelection(ev) { + ev.stopPropagation(); + this._textInputRef.comp.saveStateInStore(); + this.composer.insertIntoTextInput(ev.detail.unicode); + if (!this.env.device.isMobile) { + this.focus(); + } + } + + /** + * @private + */ + _onInputSubject() { + this.composer.update({ subjectContent: this._subjectRef.el.value }); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + if (ev.key === 'Escape') { + if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) { + return; + } + if (isEventHandled(ev, 'Composer.closeEmojisPopover')) { + return; + } + ev.preventDefault(); + this.composer.discard(); + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownEmojiButton(ev) { + if (ev.key === 'Escape') { + if (this._emojisPopoverRef.comp) { + this._emojisPopoverRef.comp.close(); + this.focus(); + markEventHandled(ev, 'Composer.closeEmojisPopover'); + } + } + } + + /** + * @private + * @param {CustomEvent} ev + */ + async _onPasteTextInput(ev) { + if (!ev.clipboardData || !ev.clipboardData.files) { + return; + } + await this._fileUploaderRef.comp.uploadFiles(ev.clipboardData.files); + } + +} + +Object.assign(Composer, { + components, + defaultProps: { + hasCurrentPartnerAvatar: true, + hasDiscardButton: false, + hasFollowers: false, + hasSendButton: true, + hasThreadName: false, + hasThreadTyping: false, + isCompact: true, + isDoFocus: false, + isExpandable: false, + }, + props: { + attachmentsDetailsMode: { + type: String, + optional: true, + }, + composerLocalId: String, + hasCurrentPartnerAvatar: Boolean, + hasDiscardButton: Boolean, + hasFollowers: Boolean, + hasMentionSuggestionsBelowPosition: { + type: Boolean, + optional: true, + }, + hasSendButton: Boolean, + hasThreadName: Boolean, + hasThreadTyping: Boolean, + /** + * Determines whether this should become focused. + */ + isDoFocus: Boolean, + showAttachmentsExtensions: { + type: Boolean, + optional: true, + }, + showAttachmentsFilenames: { + type: Boolean, + optional: true, + }, + isCompact: Boolean, + isExpandable: Boolean, + /** + * If set, keyboard shortcuts from text input to send message. + * If not set, will use default values from `ComposerTextInput`. + */ + textInputSendShortcuts: { + type: Array, + element: String, + optional: true, + }, + }, + template: 'mail.Composer', +}); + +return Composer; + +}); diff --git a/addons/mail/static/src/components/composer/composer.scss b/addons/mail/static/src/components/composer/composer.scss new file mode 100644 index 00000000..df695cce --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.scss @@ -0,0 +1,273 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Composer { + display: grid; + grid-template-areas: + "sidebar-header core-header" + "sidebar-main core-main" + "sidebar-footer core-footer"; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr auto; + + &.o-has-current-partner-avatar { + grid-template-columns: 50px 1fr; + padding: map-get($spacers, 3) map-get($spacers, 3) map-get($spacers, 4) map-get($spacers, 1); + + &:not(.o-has-footer) { + padding-bottom: 20px; + } + + &:not(.o-has-header) { + padding-top: 20px; + } + } +} + +.o_Composer_actionButtons { + &.o-composer-is-compact { + display: flex; + } + &:not(.o-composer-is-compact) { + margin-top: 10px; + } +} + +.o_Composer_attachmentList { + flex: 1 1 auto; + + &.o-composer-is-compact { + max-height: 100px; + } + + &:not(.o-composer-is-compact) { + overflow-y: auto; + max-height: 300px; + } +} + +.o_Composer_buttons { + display: flex; + align-items: stretch; + align-self: stretch; + flex: 0 0 auto; + min-height: 41px; // match minimal-height of input, including border width + + &:not(.o-composer-is-compact) { + border: 0; + height: auto; + padding: 0 10px; + width: 100%; + } +} + +.o_Composer_coreFooter { + grid-area: core-footer; + overflow-x: hidden; + + &:not(.o-composer-is-compact) { + margin-left: 0; + } +} + +.o_Composer_coreHeader { + grid-area: core-header; +} + +.o_Composer_coreMain { + grid-area: core-main; + min-width: 0; + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + flex: 1 1 auto; + + &:not(.o-composer-is-compact) { + flex-direction: column; + } +} + +.o_Composer_currentPartnerAvatar { + width: 36px; + height: 36px; +} + +.o_Composer_followers, +.o_Composer_suggestedPartners { + flex: 0 0 100%; + margin-bottom: $o-mail-chatter-gap * 0.5; +} + +.o_Composer_primaryToolButtons { + display: flex; + align-items: center; + + &.o-composer-is-compact { + padding-left: map-get($spacers, 2); + padding-right: map-get($spacers, 2); + } +} + +.o_Composer_sidebarMain { + grid-area: sidebar-main; + justify-self: center; +} + +.o_Composer_subject { + border-top: $border-width solid $border-color; + border-right: $border-width solid $border-color; + border-left: $border-width solid $border-color; + border-radius: $o-mail-rounded-rectangle-border-radius-sm $o-mail-rounded-rectangle-border-radius-sm 0 0; +} + +.o_Composer_subjectInput { + display: flex; + flex: 1; + padding: map-get($spacers, 2) map-get($spacers, 3); + border: 0; +} + +.o_Composer_textInput { + flex: 1 1 auto; + align-self: stretch; + + &:not(.o-composer-is-compact) { + border: 0; + min-height: 40px; + } +} + +.o_Composer_threadTextualTypingStatus { + font-size: $font-size-sm; + overflow: hidden; + text-overflow: ellipsis; + + &:before { + // invisible character so that typing status bar has constant height, regardless of text content. + content: "\200b"; /* unicode zero width space character */ + } +} + +.o_Composer_toolButton { + // keep a margin between the buttons to prevent their focus shadow from overlapping + margin-left: map-get($spacers, 1); + margin-right: map-get($spacers, 1); +} + +.o_Composer_toolButtons { + display: flex; + padding-top: map-get($spacers, 1); + padding-bottom: map-get($spacers, 1); + + &:not(.o-composer-is-compact) { + flex-direction: row; + justify-content: space-between; + flex: 100%; + } +} + +.o_Composer_toolButtonSeparator { + flex: 0 0 auto; + margin-top: map-get($spacers, 2); + margin-bottom: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +// TODO FIXME o-open on the button should be enough. +// Style of button when popover is "open" comes from web.Popover, and we can't +// define a modifier on .o_Composer_button due to not being aware of Popover's +// state in context of template. https://github.com/odoo/owl/issues/693 +.o_is_open .o_Composer_toolButton { + background-color: gray('200'); +} + +.o_Composer { + background-color: lighten(gray('300'), 7%); +} + +.o_Composer_actionButton.o-last.o-has-current-partner-avatar.o-composer-is-compact { + border-radius: 0 $o-mail-rounded-rectangle-border-radius-lg $o-mail-rounded-rectangle-border-radius-lg 0; +} + +.o_Composer_button.o-composer-is-compact { + border-left: none; // overrides bootstrap button style + + :last-child { + border-radius: 0 3px 3px 0; + } +} + +.o_Composer_buttonDiscard { + border: 1px solid lighten(gray('400'), 5%); +} + +.o_Composer_buttons { + border: 0; +} + +.o_Composer_coreMain:not(.o-composer-is-compact) { + background: white; + border: 1px solid lighten(gray('400'), 5%); + + // textarea should be all rounded but only when there is no subject field above + &:not(.o-composer-is-extended) { + border-radius: $o-mail-rounded-rectangle-border-radius-lg; + } +} + +.o_Composer_currentPartnerAvatar { + object-fit: cover; +} + +.o_Composer_textInput { + appearance: none; + outline: none; + background-color: white; + border: 0; + border-top: 1px solid lighten(gray('400'), 5%); + border-bottom: 1px solid lighten(gray('400'), 5%); + border-left: 1px solid lighten(gray('400'), 5%); + + &:not(.o-composer-is-compact) { + border: 0; + border-radius: $o-mail-rounded-rectangle-border-radius-lg; + } + + &.o-has-current-partner-avatar.o-composer-is-compact { + border-radius: $o-mail-rounded-rectangle-border-radius-lg 0 0 $o-mail-rounded-rectangle-border-radius-lg; + } +} + +.o_Composer_toolButton { + border: 0; // overrides bootstrap btn + background-color: white; // overrides bootstrap btn-light + color: gray('600'); // overrides bootstrap btn-light + border-radius: 50%; + + &.o-open { + background-color: gray('200'); + } +} + +.o_Composer_toolButtons { + background-color: white; + border-top: 1px solid lighten(gray('400'), 5%); + border-bottom: 1px solid lighten(gray('400'), 5%); + + &:not(.o-composer-is-compact) { + border-bottom: 0; + border-radius: initial; + } + + &:last-child:not(.o-composer-has-current-partner-avatar) { + border-right: 1px solid lighten(gray('400'), 5%); + } +} + +.o_Composer_toolButtonSeparator { + border-left: 1px solid lighten(gray('400'), 5%); +} diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml new file mode 100644 index 00000000..cc7038d3 --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.xml @@ -0,0 +1,179 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Composer" owl="1"> + <div class="o_Composer" + t-att-class="{ + 'o-focused': composer and composer.hasFocus, + 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + 'o-has-footer': hasFooter, + 'o-has-header': hasHeader, + 'o-is-compact': props.isCompact, + }" + t-on-keydown="_onKeydown" + > + <t t-if="composer"> + <t t-if="isDropZoneVisible.value"> + <DropZone + class="o_Composer_dropZone" + t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped" + t-ref="dropzone" + /> + </t> + <FileUploader + attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)" + newAttachmentExtraData="newAttachmentExtraData" + t-ref="fileUploader" + /> + <t t-if="hasHeader"> + <div class="o_Composer_coreHeader"> + <t t-if="props.hasThreadName and composer.thread"> + <span class="o_Composer_threadName"> + on: <b><t t-esc="composer.thread.displayName"/></b> + </span> + </t> + <t t-if="props.hasFollowers and !composer.isLog"> + <!-- Text for followers --> + <small class="o_Composer_followers"> + <b class="text-muted">To: </b> + <em class="text-muted">Followers of </em> + <b> + <t t-if="composer.thread and composer.thread.name"> +  "<t t-esc="composer.thread.name"/>" + </t> + <t t-else=""> + this document + </t> + </b> + </small> + <ComposerSuggestedRecipientList + threadLocalId="composer.thread.localId" + /> + </t> + </div> + </t> + <t t-if="composer.thread and composer.thread.model === 'mail.channel' and composer.thread.mass_mailing"> + <div class="o_Composer_subject"> + <input class="o_Composer_subjectInput" type="text" placeholder="Subject" t-on-input="_onInputSubject" t-ref="subject"/> + </div> + </t> + <t t-if="props.hasCurrentPartnerAvatar"> + <div class="o_Composer_sidebarMain"> + <img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt=""/> + </div> + </t> + <div + class="o_Composer_coreMain" + t-att-class="{ + 'o-composer-is-compact': props.isCompact, + 'o-composer-is-extended': composer.thread and composer.thread.mass_mailing, + }" + > + <TextInput + class="o_Composer_textInput" + t-att-class="{ + 'o-composer-is-compact': props.isCompact, + 'o_Composer_textInput-mobile': env.messaging.device.isMobile, + 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + }" + composerLocalId="composer.localId" + hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition" + isCompact="props.isCompact" + sendShortcuts="props.textInputSendShortcuts" + t-on-o-composer-suggestion-clicked="_onComposerSuggestionClicked" + t-on-o-composer-text-input-send-shortcut="_onComposerTextInputSendShortcut" + t-on-paste="_onPasteTextInput" + t-key="composer.localId" + t-ref="textInput" + /> + <div class="o_Composer_buttons" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-mobile': env.messaging.device.isMobile }"> + <div class="o_Composer_toolButtons" + t-att-class="{ + 'o-composer-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + 'o-composer-is-compact': props.isCompact, + }"> + <t t-if="props.isCompact"> + <div class="o_Composer_toolButtonSeparator"/> + </t> + <div class="o_Composer_primaryToolButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection"> + <!-- TODO FIXME o-open not possible to code due to https://github.com/odoo/owl/issues/693 --> + <button class="o_Composer_button o_Composer_buttonEmojis o_Composer_toolButton btn btn-light" + t-att-class="{ + 'o-open': false and state.displayed, + 'o-mobile': env.messaging.device.isMobile, + }" + t-on-keydown="_onKeydownEmojiButton" + > + <i class="fa fa-smile-o"/> + </button> + <t t-set="opened"> + <EmojisPopover t-ref="emojisPopover"/> + </t> + </Popover> + <button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-light fa fa-paperclip" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Add attachment" type="button" t-on-click="_onClickAddAttachment"/> + </div> + <t t-if="props.isExpandable"> + <div class="o_Composer_secondaryToolButtons"> + <button class="btn btn-light fa fa-expand o_Composer_button o_Composer_buttonFullComposer o_Composer_toolButton" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Full composer" type="button" t-on-click="_onClickFullComposer"/> + </div> + </t> + </div> + <t t-if="props.isCompact"> + <t t-call="mail.Composer.actionButtons"/> + </t> + </div> + </div> + <t t-if="hasFooter"> + <div class="o_Composer_coreFooter" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <t t-if="props.hasThreadTyping"> + <ThreadTextualTypingStatus class="o_Composer_threadTextualTypingStatus" threadLocalId="composer.thread.localId"/> + </t> + <t t-if="composer.attachments.length > 0"> + <AttachmentList + class="o_Composer_attachmentList" + t-att-class="{ 'o-composer-is-compact': props.isCompact }" + areAttachmentsEditable="true" + attachmentsDetailsMode="props.attachmentsDetailsMode" + attachmentsImageSize="'small'" + attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)" + showAttachmentsExtensions="props.showAttachmentsExtensions" + showAttachmentsFilenames="props.showAttachmentsFilenames" + /> + </t> + <t t-if="!props.isCompact"> + <t t-call="mail.Composer.actionButtons"/> + </t> + </div> + </t> + </t> + </div> + </t> + + <t t-name="mail.Composer.actionButtons" owl="1"> + <div class="o_Composer_actionButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <t t-if="props.hasSendButton"> + <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonSend btn btn-primary" + t-att-class="{ + 'fa': env.messaging.device.isMobile, + 'fa-paper-plane-o': env.messaging.device.isMobile, + 'o-last': env.messaging.device.isMobile or !props.hasDiscardButton, + 'o-composer-is-compact': props.isCompact, + 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + }" + t-att-disabled="!composer.canPostMessage ? 'disabled' : ''" + type="button" + t-on-click="_onClickSend" + > + <t t-if="!env.messaging.device.isMobile"><t t-if="composer.isLog">Log</t><t t-else="">Send</t></t> + </button> + </t> + <t t-if="!env.messaging.device.isMobile and props.hasDiscardButton"> + <button class="o_Composer_actionButton o-last o_Composer_button o_Composer_buttonDiscard btn btn-secondary" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar }" type="button" t-on-click="_onClickDiscard"> + Discard + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js new file mode 100644 index 00000000..a4ff5978 --- /dev/null +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -0,0 +1,2153 @@ +odoo.define('mail/static/src/components/composer/composer_tests.js', function (require) { +'use strict'; + +const components = { + Composer: require('mail/static/src/components/composer/composer.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + dragenterFiles, + dropFiles, + nextAnimationFrame, + pasteFiles, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const { + file: { + createFile, + inputFiles, + }, + makeTestPromise, +} = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer', {}, function () { +QUnit.module('composer_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerComponent = async (composer, otherProps) => { + const props = Object.assign({ composerLocalId: composer.localId }, otherProps); + await createRootComponent(this, components.Composer, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { afterEvent, env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.afterEvent = afterEvent; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('composer text input: basic rendering when posting a message', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: false }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer); + assert.strictEqual( + document.querySelectorAll('.o_Composer').length, + 1, + "should have composer in discuss thread" + ); + assert.strictEqual( + document.querySelectorAll('.o_Composer_textInput').length, + 1, + "should have text input inside discuss thread composer" + ); + assert.ok( + document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'), + "composer text input of composer should be a ComposerTextIput component" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ComposerTextInput_textarea`).length, + 1, + "should have editable part inside composer text input" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).placeholder, + "Send a message to followers...", + "should have 'Send a message to followers...' as placeholder composer text input" + ); +}); + +QUnit.test('composer text input: basic rendering when logging note', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: true }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer); + assert.strictEqual( + document.querySelectorAll('.o_Composer').length, + 1, + "should have composer in discuss thread" + ); + assert.strictEqual( + document.querySelectorAll('.o_Composer_textInput').length, + 1, + "should have text input inside discuss thread composer" + ); + assert.ok( + document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'), + "composer text input of composer should be a ComposerTextIput component" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ComposerTextInput_textarea`).length, + 1, + "should have editable part inside composer text input" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).placeholder, + "Log an internal note...", + "should have 'Log an internal note...' as placeholder in composer text input if composer is log" + ); +}); + +QUnit.test('composer text input: basic rendering when linked thread is a mail.channel', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + assert.strictEqual( + document.querySelectorAll('.o_Composer').length, + 1, + "should have composer in discuss thread" + ); + assert.strictEqual( + document.querySelectorAll('.o_Composer_textInput').length, + 1, + "should have text input inside discuss thread composer" + ); + assert.ok( + document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'), + "composer text input of composer should be a ComposerTextIput component" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ComposerTextInput_textarea`).length, + 1, + "should have editable part inside composer text input" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).placeholder, + "Write something...", + "should have 'Write something...' as placeholder in composer text input if composer is for a 'mail.channel'" + ); +}); + +QUnit.test('mailing channel composer: basic rendering', async function (assert) { + assert.expect(2); + + // channel that is expected to be rendered, with proper mass_mailing + // value and a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: true }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + assert.containsOnce( + document.body, + '.o_ComposerTextInput', + "Composer should have a text input" + ); + assert.containsOnce( + document.body, + '.o_Composer_subjectInput', + "Composer should have a subject input" + ); +}); + +QUnit.test('add an emoji', async function (assert) { + assert.expect(1); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "😊", + "emoji should be inserted in the composer text input" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('add an emoji after some text', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Blabla"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Blabla", + "composer text input should have text only initially" + ); + + await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Blabla😊", + "emoji should be inserted after the text" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('add emoji replaces (keyboard) text selection', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + await afterNextRender(() => { + composerTextInputTextArea.focus(); + document.execCommand('insertText', false, "Blabla"); + }); + assert.strictEqual( + composerTextInputTextArea.value, + "Blabla", + "composer text input should have text only initially" + ); + + // simulate selection of all the content by keyboard + composerTextInputTextArea.setSelectionRange(0, composerTextInputTextArea.value.length); + + // select emoji + await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "😊", + "whole text selection should have been replaced by emoji" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('display canned response suggestions on typing ":"', async function (assert) { + assert.expect(2); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "Canned responses suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display canned response suggestions on typing ':'" + ); +}); + +QUnit.test('use a canned response', async function (assert) { + assert.expect(4); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? ", + "text content of composer should have canned response + additional whitespace afterwards" + ); +}); + +QUnit.test('use a canned response some text', async function (assert) { + assert.expect(5); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => { + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh Hello! How are you? ", + "text content of composer should have previous content + canned response substitution + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a canned response', async function (assert) { + assert.expect(5); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? ", + "text content of composer should have previous content + canned response substitution + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? 😊", + "text content of composer should have previous canned response substitution and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display channel mention suggestions on typing "#"', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "channel mention suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display channel mention suggestions on typing '#'" + ); +}); + +QUnit.test('mention a channel', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "channel mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General ", + "text content of composer should have mentioned channel + additional whitespace afterwards" + ); +}); + +QUnit.test('mention a channel after some text', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "channel mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => { + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh #General ", + "text content of composer should have previous content + mentioned channel + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a channel mention', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General ", + "text content of composer should have previous content + mentioned channel + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General 😊", + "text content of composer should have previous channel mention and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display command suggestions on typing "/"', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "command suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display command suggestions on typing '/'" + ); +}); + +QUnit.test('do not send typing notification on typing "/" command', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.channel_command'].records.push({ + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.verifySteps([], "No rpc done"); +}); + +QUnit.test('do not send typing notification on typing after selecting suggestion from "/" command', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.channel_command'].records.push({ + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, " is user?"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.verifySteps([], "No rpc done"); +}); + +QUnit.test('use a command for a specific channel type', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a command suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who ", + "text content of composer should have used command + additional whitespace afterwards" + ); +}); + +QUnit.test("channel with no commands should not prompt any command suggestions on typing /", async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ channel_type: 'chat', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "bla bla bla", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + await afterNextRender(() => { + document.querySelector('.o_ComposerTextInput_textarea').focus(); + document.execCommand('insertText', false, "/"); + const composer_text_input = document.querySelector('.o_ComposerTextInput_textarea'); + composer_text_input.dispatchEvent(new window.KeyboardEvent('keydown')); + composer_text_input.dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "should not prompt (command) suggestion after typing / (reason: no channel commands in chat channels)" + ); +}); + +QUnit.test('command suggestion should only open if command is the first character', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => { + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "should not have a command suggestion" + ); +}); + +QUnit.test('add an emoji after a command', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 }); + this.data['mail.channel_command'].records.push( + { + channel_types: ['channel'], + help: "List users in the current channel", + name: "who", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a command suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who ", + "text content of composer should have previous content + used command + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who 😊", + "text content of composer should have previous command and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display partner mention suggestions on typing "@"', async function (assert) { + assert.expect(3); + + this.data['res.partner'].records.push({ + id: 11, + email: "testpartner@odoo.com", + name: "TestPartner", + }); + this.data['res.partner'].records.push({ + id: 12, + email: "testpartner2@odoo.com", + name: "TestPartner2", + }); + this.data['res.users'].records.push({ + partner_id: 11, + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "mention suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display mention suggestions on typing '@'" + ); + assert.containsOnce( + document.body, + '.dropdown-divider', + "should have a separator" + ); +}); + +QUnit.test('mention a partner', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ + email: "testpartner@odoo.com", + name: "TestPartner", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner ", + "text content of composer should have mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('mention a partner after some text', async function (assert) { + assert.expect(5); + + this.data['res.partner'].records.push({ + email: "testpartner@odoo.com", + name: "TestPartner", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh @TestPartner ", + "text content of composer should have previous content + mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a partner mention', async function (assert) { + assert.expect(5); + + this.data['res.partner'].records.push({ + email: "testpartner@odoo.com", + name: "TestPartner", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner ", + "text content of composer should have previous content + mentioned partner + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner 😊", + "text content of composer should have previous mention and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('composer: add an attachment', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer, { attachmentsDetailsMode: 'card' }); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.ok( + document.querySelector('.o_Composer_attachmentList'), + "should have an attachment list" + ); + assert.ok( + document.querySelector(`.o_Composer .o_Attachment`), + "should have an attachment" + ); +}); + +QUnit.test('composer: drop attachments', async function (assert) { + assert.expect(4); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }), + await createFile({ + content: 'hello, worlduh', + contentType: 'text/plain', + name: 'text2.txt', + }), + ]; + await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer'))); + assert.ok( + document.querySelector('.o_Composer_dropZone'), + "should have a drop zone" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 0, + "should have no attachment before files are dropped" + ); + + await afterNextRender(() => + dropFiles( + document.querySelector('.o_Composer_dropZone'), + files + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "should have 2 attachments in the composer after files dropped" + ); + + await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer'))); + await afterNextRender(async () => + dropFiles( + document.querySelector('.o_Composer_dropZone'), + [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text3.txt', + }) + ] + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 3, + "should have 3 attachments in the box after files dropped" + ); +}); + +QUnit.test('composer: paste attachments', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }) + ]; + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 0, + "should not have any attachment in the composer before paste" + ); + + await afterNextRender(() => + pasteFiles(document.querySelector('.o_ComposerTextInput'), files) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 1, + "should have 1 attachment in the composer after paste" + ); +}); + +QUnit.test('send message when enter is pressed while holding ctrl key (this shortcut is available)', async function (assert) { + // Note that test doesn't assert ENTER makes no newline, because this + // default browser cannot be simulated with just dispatching + // programmatically crafted events... + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { + textInputSendShortcuts: ['ctrl-enter'], + }); + // Type message + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + await afterNextRender(() => { + const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' }); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(enterEvent); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable as message has not been posted" + ); + + // Send message with ctrl+enter + await afterNextRender(() => + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' })) + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input as message has been posted" + ); +}); + +QUnit.test('send message when enter is pressed while holding meta key (this shortcut is available)', async function (assert) { + // Note that test doesn't assert ENTER makes no newline, because this + // default browser cannot be simulated with just dispatching + // programmatically crafted events... + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { + textInputSendShortcuts: ['meta-enter'], + }); + // Type message + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + await afterNextRender(() => { + const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' }); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(enterEvent); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable as message has not been posted" + ); + + // Send message with meta+enter + await afterNextRender(() => + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', metaKey: true })) + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input as message has been posted" + ); +}); + +QUnit.test('composer text input cleared on message post', async function (assert) { + assert.expect(4); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + // Type message + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + // Send message + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); +}); + +QUnit.test('composer inputs cleared on message post in composer of a mailing channel', async function (assert) { + assert.expect(10); + + // channel that is expected to be rendered, with proper mass_mailing + // value and a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: true }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + assert.ok( + 'body' in args.kwargs, + "body should be posted with the message" + ); + assert.strictEqual( + args.kwargs.body, + "test message", + "posted body should be the one typed in text input" + ); + assert.ok( + 'subject' in args.kwargs, + "subject should be posted with the message" + ); + assert.strictEqual( + args.kwargs.subject, + "test subject", + "posted subject should be the one typed in subject input" + ); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + // Type message + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Composer_subjectInput`).focus(); + document.execCommand('insertText', false, "test subject"); + }); + assert.strictEqual( + document.querySelector(`.o_Composer_subjectInput`).value, + "test subject", + "should have inserted text content in input" + ); + + // Send message + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); + assert.strictEqual( + document.querySelector(`.o_Composer_subjectInput`).value, + "", + "should have no content in composer subject input after posting message" + ); +}); + +QUnit.test('composer with thread typing notification status', async function (assert) { + assert.expect(2); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + assert.containsOnce( + document.body, + '.o_Composer_threadTextualTypingStatus', + "Composer should have a thread textual typing status bar" + ); + assert.strictEqual( + document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent, + "", + "By default, thread textual typing status bar should be empty" + ); +}); + +QUnit.test('current partner notify is typing to other thread members', async function (assert) { + assert.expect(2); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner typing status" + ); +}); + +QUnit.test('current partner is typing should not translate on textual typing status', async function (assert) { + assert.expect(3); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner typing status" + ); + + await nextAnimationFrame(); + assert.strictEqual( + document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent, + "", + "Thread textual typing status bar should not display current partner is typing" + ); +}); + +QUnit.test('current partner notify no longer is typing to thread members after 5 seconds inactivity', async function (assert) { + assert.expect(4); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is typing" + ); + + await this.env.testUtils.advanceTime(5 * 1000); + assert.verifySteps( + ['notify_typing:false'], + "should have notified current partner no longer is typing (inactive for 5 seconds)" + ); +}); + +QUnit.test('current partner notify is typing again to other members every 50s of long continuous typing', async function (assert) { + assert.expect(4); + + // channel that is expected to be rendered + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is typing" + ); + + // simulate current partner typing a character every 2.5 seconds for 50 seconds straight. + let totalTimeElapsed = 0; + const elapseTickTime = 2.5 * 1000; + while (totalTimeElapsed < 50 * 1000) { + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + totalTimeElapsed += elapseTickTime; + await this.env.testUtils.advanceTime(elapseTickTime); + } + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is still typing after 50s of straight typing" + ); +}); + +QUnit.test('composer: send button is disabled if attachment upload is not finished', async function (assert) { + assert.expect(8); + + const attachmentUploadedPromise = makeTestPromise(); + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + await attachmentUploadedPromise; + } + return res; + } + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have an attachment after a file has been input" + ); + assert.containsOnce( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed is being uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should be displayed" + ); + assert.ok( + !!document.querySelector('.o_Composer_buttonSend').attributes.disabled, + "composer send button should be disabled as attachment is not yet uploaded" + ); + + // simulates attachment finishes uploading + await afterNextRender(() => attachmentUploadedPromise.resolve()); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have only one attachment" + ); + assert.containsNone( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed should be uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should still be present" + ); + assert.ok( + !document.querySelector('.o_Composer_buttonSend').attributes.disabled, + "composer send button should be enabled as attachment is now uploaded" + ); +}); + +QUnit.test('warning on send with shortcut when attempting to post message with still-uploading attachments', async function (assert) { + assert.expect(7); + + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + // simulates attachment is never finished uploading + await new Promise(() => {}); + } + return res; + }, + services: { + notification: { + notify(params) { + assert.strictEqual( + params.message, + "Please wait while the file is uploading.", + "notification content should be about the uploading file" + ); + assert.strictEqual( + params.type, + 'warning', + "notification should be a warning" + ); + assert.step('notification'); + } + } + }, + }); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: false }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer, { + textInputSendShortcuts: ['enter'], + }); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have only one attachment" + ); + assert.containsOnce( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed is being uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should be displayed" + ); + + // Try to send message + document + .querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })); + assert.verifySteps( + ['notification'], + "should have triggered a notification for inability to post message at the moment (some attachments are still being uploaded)" + ); +}); + +QUnit.test('remove an attachment from composer does not need any confirmation', async function (assert) { + assert.expect(3); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Composer_attachmentList', + "should have an attachment list" + ); + assert.containsOnce( + document.body, + '.o_Composer .o_Attachment', + "should have only one attachment" + ); + + await afterNextRender(() => + document.querySelector('.o_Attachment_asideItemUnlink').click() + ); + assert.containsNone( + document.body, + '.o_Composer .o_Attachment', + "should not have any attachment left after unlinking the only one" + ); +}); + +QUnit.test('remove an uploading attachment', async function (assert) { + assert.expect(4); + + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + // simulates uploading indefinitely + await new Promise(() => {}); + } + return res; + } + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Composer_attachmentList', + "should have an attachment list" + ); + assert.containsOnce( + document.body, + '.o_Composer .o_Attachment', + "should have only one attachment" + ); + assert.containsOnce( + document.body, + '.o_Composer .o_Attachment.o-temporary', + "should have an uploading attachment" + ); + + await afterNextRender(() => + document.querySelector('.o_Attachment_asideItemUnlink').click()); + assert.containsNone( + document.body, + '.o_Composer .o_Attachment', + "should not have any attachment left after unlinking temporary one" + ); +}); + +QUnit.test('remove an uploading attachment aborts upload', async function (assert) { + assert.expect(1); + + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + // simulates uploading indefinitely + await new Promise(() => {}); + } + return res; + } + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Attachment', + "should contain an attachment" + ); + const attachmentLocalId = document.querySelector('.o_Attachment').dataset.attachmentLocalId; + + await this.afterEvent({ + eventName: 'o-attachment-upload-abort', + func: () => { + document.querySelector('.o_Attachment_asideItemUnlink').click(); + }, + message: "attachment upload request should have been aborted", + predicate: ({ attachment }) => { + return attachment.localId === attachmentLocalId; + }, + }); +}); + +QUnit.test("basic rendering when sending a message to the followers and thread doesn't have a name", async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: false }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer, { hasFollowers: true }); + assert.strictEqual( + document.querySelector('.o_Composer_followers').textContent.replace(/\s+/g, ''), + "To:Followersofthisdocument", + "Composer should display \"To: Followers of this document\" if the thread as no name." + ); +}); + +QUnit.test('send message only once when button send is clicked twice quickly', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer); + // Type message + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + }); + + await afterNextRender(() => { + document.querySelector(`.o_Composer_buttonSend`).click(); + document.querySelector(`.o_Composer_buttonSend`).click(); + }); + assert.verifySteps( + ['message_post'], + "The message has been posted only once" + ); +}); + +QUnit.test('send message only once when enter is pressed twice quickly', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createComposerComponent(thread.composer, { + textInputSendShortcuts: ['enter'], + }); + // Type message + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + }); + await afterNextRender(() => { + const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' }); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(enterEvent); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(enterEvent); + }); + assert.verifySteps( + ['message_post'], + "The message has been posted only once" + ); +}); + +QUnit.test('[technical] does not crash when an attachment is removed before its upload starts', async function (assert) { + // Uploading multiple files uploads attachments one at a time, this test + // ensures that there is no crash when an attachment is destroyed before its + // upload started. + assert.expect(1); + + // Promise to block attachment uploading + const uploadPromise = makeTestPromise(); + await this.start({ + async mockFetch(resource) { + const _super = this._super.bind(this, ...arguments); + if (resource === '/web/binary/upload_attachment') { + await uploadPromise; + } + return _super(); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file1 = await createFile({ + name: 'text1.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + const file2 = await createFile({ + name: 'text2.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file1, file2] + ) + ); + await afterNextRender(() => { + Array.from(document.querySelectorAll('div')) + .find(el => el.textContent === 'text2.txt') + .closest('.o_Attachment') + .querySelector('.o_Attachment_asideItemUnlink') + .click(); + } + ); + // Simulates the completion of the upload of the first attachment + uploadPromise.resolve(); + assert.containsOnce( + document.body, + '.o_Attachment:contains("text1.txt")', + "should only have the first attachment after cancelling the second attachment" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js new file mode 100644 index 00000000..efedf662 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js @@ -0,0 +1,158 @@ +odoo.define('mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const { FormViewDialog } = require('web.view_dialogs'); +const { ComponentAdapter } = require('web.OwlCompatibility'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class FormViewDialogComponentAdapter extends ComponentAdapter { + + renderWidget() { + // Ensure the dialog is properly reconstructed. Without this line, it is + // impossible to open the dialog again after having it closed a first + // time, because the DOM of the dialog has disappeared. + return this.willStart(); + } + +} + +const components = { + FormViewDialogComponentAdapter, +}; + +class ComposerSuggestedRecipient extends Component { + + constructor(...args) { + super(...args); + this.id = _.uniqueId('o_ComposerSuggestedRecipient_'); + useShouldUpdateBasedOnProps(); + useStore(props => { + const suggestedRecipientInfo = this.env.models['mail.suggested_recipient_info'].get(props.suggestedRecipientLocalId); + const partner = suggestedRecipientInfo && suggestedRecipientInfo.partner; + return { + partner: partner && partner.__state, + suggestedRecipientInfo: suggestedRecipientInfo && suggestedRecipientInfo.__state, + }; + }); + useUpdate({ func: () => this._update() }); + /** + * Form view dialog class. Useful to reference it in the template. + */ + this.FormViewDialog = FormViewDialog; + /** + * Reference of the checkbox. Useful to know whether it was checked or + * not, to properly update the corresponding state in the record or to + * prompt the user with the partner creation dialog. + */ + this._checkboxRef = useRef('checkbox'); + /** + * Reference of the partner creation dialog. Useful to open it, for + * compatibility with old code. + */ + this._dialogRef = useRef('dialog'); + /** + * Whether the dialog is currently open. `_dialogRef` cannot be trusted + * to know if the dialog is open due to manually calling `open` and + * potential out of sync with component adapter. + */ + this._isDialogOpen = false; + this._onDialogSaved = this._onDialogSaved.bind(this); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string|undefined} + */ + get ADD_AS_RECIPIENT_AND_FOLLOWER_REASON() { + if (!this.suggestedRecipientInfo) { + return undefined; + } + return this.env._t(_.str.sprintf( + "Add as recipient and follower (reason: %s)", + this.suggestedRecipientInfo.reason + )); + } + + /** + * @returns {string} + */ + get PLEASE_COMPLETE_CUSTOMER_S_INFORMATION() { + return this.env._t("Please complete customer's information"); + } + + /** + * @returns {mail.suggested_recipient_info} + */ + get suggestedRecipientInfo() { + return this.env.models['mail.suggested_recipient_info'].get(this.props.suggestedRecipientInfoLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _update() { + if (this._checkboxRef.el && this.suggestedRecipientInfo) { + this._checkboxRef.el.checked = this.suggestedRecipientInfo.isSelected; + } + } + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onChangeCheckbox() { + const isChecked = this._checkboxRef.el.checked; + this.suggestedRecipientInfo.update({ isSelected: isChecked }); + if (!this.suggestedRecipientInfo.partner) { + // Recipients must always be partners. On selecting a suggested + // recipient that does not have a partner, the partner creation form + // should be opened. + if (isChecked && this._dialogRef && !this._isDialogOpen) { + this._isDialogOpen = true; + this._dialogRef.comp.widget.on('closed', this, () => { + this._isDialogOpen = false; + }); + this._dialogRef.comp.widget.open(); + } + } + } + + /** + * @private + */ + _onDialogSaved() { + const thread = this.suggestedRecipientInfo && this.suggestedRecipientInfo.thread; + if (!thread) { + return; + } + thread.fetchAndUpdateSuggestedRecipients(); + } +} + +Object.assign(ComposerSuggestedRecipient, { + components, + props: { + suggestedRecipientInfoLocalId: String, + }, + template: 'mail.ComposerSuggestedRecipient', +}); + +return ComposerSuggestedRecipient; + +}); diff --git a/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.scss b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.scss new file mode 100644 index 00000000..c8b6b5ad --- /dev/null +++ b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.scss @@ -0,0 +1,5 @@ +// Dirty fix: clear modal-body padding, otherwise it create space inside the +// suggested_recipient list. +.o_ComposerSuggestedRecipient .modal-body { + padding: 0; +} diff --git a/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.xml b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.xml new file mode 100644 index 00000000..4e754359 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ComposerSuggestedRecipient" owl="1"> + <div class="o_ComposerSuggestedRecipient" t-att-data-partner-id="suggestedRecipientInfo.partner and suggestedRecipientInfo.partner.id ? suggestedRecipientInfo.partner.id : false" t-att-title="ADD_AS_RECIPIENT_AND_FOLLOWER_REASON"> + <t t-if="suggestedRecipientInfo"> + <div class="custom-control custom-checkbox"> + <input t-attf-id="{{ id }}_checkbox" class="custom-control-input" type="checkbox" t-att-checked="suggestedRecipientInfo.isSelected ? 'checked' : undefined" t-on-change="_onChangeCheckbox" t-ref="checkbox" /> + <label class="custom-control-label" t-attf-for="{{ id }}_checkbox"> + <t t-if="suggestedRecipientInfo.name"> + <t t-esc="suggestedRecipientInfo.name"/> + </t> + <t t-if="suggestedRecipientInfo.email"> + (<t t-esc="suggestedRecipientInfo.email"/>) + </t> + </label> + </div> + <t t-if="!suggestedRecipientInfo.partner"> + <FormViewDialogComponentAdapter + Component="FormViewDialog" + params="{ + context: { + active_id: suggestedRecipientInfo.thread.id, + active_model: 'mail.compose.message', + default_email: suggestedRecipientInfo.email, + default_name: suggestedRecipientInfo.name, + force_email: true, + ref: 'compound_context', + }, + disable_multiple_selection: true, + on_saved: _onDialogSaved, + res_id: false, + res_model: 'res.partner', + title: PLEASE_COMPLETE_CUSTOMER_S_INFORMATION, + }" + t-ref="dialog" + /> + </t> + </t> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js new file mode 100644 index 00000000..61da098c --- /dev/null +++ b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js @@ -0,0 +1,77 @@ +odoo.define('mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useState } = owl.hooks; + +const components = { + ComposerSuggestedRecipient: require('mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js'), +}; + +class ComposerSuggestedRecipientList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + this.state = useState({ + hasShowMoreButton: false, + }); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + threadSuggestedRecipientInfoList: thread ? thread.suggestedRecipientInfoList : [], + }; + }, { + compareDepth: { + threadSuggestedRecipientInfoList: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickShowLess(ev) { + this.state.hasShowMoreButton = false; + } + + /** + * @private + */ + _onClickShowMore(ev) { + this.state.hasShowMoreButton = true; + } + +} + +Object.assign(ComposerSuggestedRecipientList, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ComposerSuggestedRecipientList', +}); + +return ComposerSuggestedRecipientList; +}); diff --git a/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.scss b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.scss new file mode 100644 index 00000000..4e1b4dd7 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.scss @@ -0,0 +1,3 @@ +.o_ComposerSuggestedRecipientList { + margin-bottom: map-get($spacers, 2); +} diff --git a/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.xml b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.xml new file mode 100644 index 00000000..c78570db --- /dev/null +++ b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ComposerSuggestedRecipientList" owl="1"> + <div class="o_ComposerSuggestedRecipientList"> + <t t-if="thread"> + <t t-foreach="state.hasShowMoreButton ? thread.suggestedRecipientInfoList : thread.suggestedRecipientInfoList.slice(0,3)" t-as="recipientInfo" t-key="recipientInfo.localId"> + <ComposerSuggestedRecipient + suggestedRecipientInfoLocalId="recipientInfo.localId" + /> + </t> + <t t-if="thread.suggestedRecipientInfoList.length > 3"> + <t t-if="!state.hasShowMoreButton" > + <button class="o_ComposerSuggestedRecipientList_showMore btn btn-sm btn-link" t-on-click="_onClickShowMore"> + Show more + </button> + </t> + <t t-else=""> + <button class="o_ComposerSuggestedRecipientList_showLess btn btn-sm btn-link" t-on-click="_onClickShowLess"> + Show less + </button> + </t> + </t> + </t> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js new file mode 100644 index 00000000..da54ab54 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js @@ -0,0 +1,143 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const components = { + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; + +const { Component } = owl; + +class ComposerSuggestion extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const composer = this.env.models['mail.composer'].get(this.props.composerLocalId); + const record = this.env.models[props.modelName].get(props.recordLocalId); + return { + composerHasToScrollToActiveSuggestion: composer && composer.hasToScrollToActiveSuggestion, + record: record ? record.__state : undefined, + }; + }); + useUpdate({ func: () => this._update() }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + get isCannedResponse() { + return this.props.modelName === "mail.canned_response"; + } + + get isChannel() { + return this.props.modelName === "mail.thread"; + } + + get isCommand() { + return this.props.modelName === "mail.channel_command"; + } + + get isPartner() { + return this.props.modelName === "mail.partner"; + } + + get record() { + return this.env.models[this.props.modelName].get(this.props.recordLocalId); + } + + /** + * Returns a descriptive title for this suggestion. Useful to be able to + * read both parts when they are overflowing the UI. + * + * @returns {string} + */ + title() { + if (this.isCannedResponse) { + return _.str.sprintf("%s: %s", this.record.source, this.record.substitution); + } + if (this.isChannel) { + return this.record.name; + } + if (this.isCommand) { + return _.str.sprintf("%s: %s", this.record.name, this.record.help); + } + if (this.isPartner) { + if (this.record.email) { + return _.str.sprintf("%s (%s)", this.record.nameOrDisplayName, this.record.email); + } + return this.record.nameOrDisplayName; + } + return ""; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _update() { + if ( + this.composer && + this.composer.hasToScrollToActiveSuggestion && + this.props.isActive + ) { + this.el.scrollIntoView({ + block: 'center', + }); + this.composer.update({ hasToScrollToActiveSuggestion: false }); + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onClick(ev) { + ev.preventDefault(); + this.composer.update({ activeSuggestedRecord: [['link', this.record]] }); + this.composer.insertSuggestion(); + this.composer.closeSuggestions(); + this.trigger('o-composer-suggestion-clicked'); + } + +} + +Object.assign(ComposerSuggestion, { + components, + defaultProps: { + isActive: false, + }, + props: { + composerLocalId: String, + isActive: Boolean, + modelName: String, + recordLocalId: String, + }, + template: 'mail.ComposerSuggestion', +}); + +return ComposerSuggestion; + +}); diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss new file mode 100644 index 00000000..4083c149 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ComposerSuggestion { + display: flex; + width: map-get($sizes, 100); + padding: map-get($spacers, 2) map-get($spacers, 4); +} + +.o_ComposerSuggestion_part1 { + // avoid shrinking part 1 because it is more important than part 2 + // because no shrink, ensure it cannot overflow with a max-width + flex: 0 0 auto; + max-width: 100%; + overflow: hidden; + padding-inline-end: map-get($spacers, 2); + text-overflow: ellipsis; +} + +.o_ComposerSuggestion_part2 { + // shrink part 2 to properly ensure it cannot overflow + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; +} + +.o_ComposerSuggestion_partnerImStatusIcon { + flex: 0 0 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ComposerSuggestion_part1 { + font-weight: $font-weight-bold; +} + +.o_ComposerSuggestion_part2 { + font-style: italic; + color: $gray-600; +} diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml new file mode 100644 index 00000000..787b5aed --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ComposerSuggestion" owl="1"> + <a class="o_ComposerSuggestion dropdown-item" t-att-class="{ 'active': props.isActive }" href="#" t-att-title="title()" role="menuitem" t-on-click="_onClick"> + <t t-if="record"> + <t t-if="isCannedResponse"> + <span class="o_ComposerSuggestion_part1"><t t-esc="record.source"/></span> + <span class="o_ComposerSuggestion_part2"><t t-esc="record.substitution"/></span> + </t> + <t t-if="isChannel"> + <span class="o_ComposerSuggestion_part1"><t t-esc="record.name"/></span> + </t> + <t t-if="isCommand"> + <span class="o_ComposerSuggestion_part1"><t t-esc="record.name"/></span> + <span class="o_ComposerSuggestion_part2"><t t-esc="record.help"/></span> + </t> + <t t-if="isPartner"> + <PartnerImStatusIcon + class="o_ComposerSuggestion_partnerImStatusIcon" + hasBackground="false" + partnerLocalId="record.localId" + /> + <span class="o_ComposerSuggestion_part1"><t t-esc="record.nameOrDisplayName"/></span> + <t t-if="record.email"> + <span class="o_ComposerSuggestion_part2">(<t t-esc="record.email"/>)</span> + </t> + </t> + </t> + </a> + </t> + +</templates> diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js new file mode 100644 index 00000000..0e0f8685 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js @@ -0,0 +1,154 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer_suggestion', {}, function () { +QUnit.module('composer_suggestion_canned_response_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerSuggestion = async props => { + await createRootComponent(this, components.ComposerSuggestion, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('canned response suggestion displayed', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const cannedResponse = this.env.models['mail.canned_response'].create({ + id: 7, + source: 'hello', + substitution: "Hello, how are you?", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.canned_response', + recordLocalId: cannedResponse.localId, + }); + + assert.containsOnce( + document.body, + `.o_ComposerSuggestion`, + "Canned response suggestion should be present" + ); +}); + +QUnit.test('canned response suggestion correct data', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const cannedResponse = this.env.models['mail.canned_response'].create({ + id: 7, + source: 'hello', + substitution: "Hello, how are you?", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.canned_response', + recordLocalId: cannedResponse.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Canned response suggestion should be present" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part1', + "Canned response source should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part1`).textContent, + "hello", + "Canned response source should be displayed" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part2', + "Canned response substitution should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part2`).textContent, + "Hello, how are you?", + "Canned response substitution should be displayed" + ); +}); + +QUnit.test('canned response suggestion active', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const cannedResponse = this.env.models['mail.canned_response'].create({ + id: 7, + source: 'hello', + substitution: "Hello, how are you?", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.canned_response', + recordLocalId: cannedResponse.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Canned response suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_ComposerSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js new file mode 100644 index 00000000..7a211483 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js @@ -0,0 +1,144 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer_suggestion', {}, function () { +QUnit.module('composer_suggestion_channel_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerSuggestion = async props => { + await createRootComponent(this, components.ComposerSuggestion, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('channel mention suggestion displayed', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const channel = this.env.models['mail.thread'].create({ + id: 7, + name: "General", + model: 'mail.channel', + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.thread', + recordLocalId: channel.localId, + }); + + assert.containsOnce( + document.body, + `.o_ComposerSuggestion`, + "Channel mention suggestion should be present" + ); +}); + +QUnit.test('channel mention suggestion correct data', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const channel = this.env.models['mail.thread'].create({ + id: 7, + name: "General", + model: 'mail.channel', + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.thread', + recordLocalId: channel.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Channel mention suggestion should be present" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part1', + "Channel name should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part1`).textContent, + "General", + "Channel name should be displayed" + ); +}); + +QUnit.test('channel mention suggestion active', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const channel = this.env.models['mail.thread'].create({ + id: 7, + name: "General", + model: 'mail.channel', + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.thread', + recordLocalId: channel.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Channel mention suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_ComposerSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js new file mode 100644 index 00000000..8bbb3d45 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js @@ -0,0 +1,151 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer_suggestion', {}, function () { +QUnit.module('composer_suggestion_command_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerSuggestion = async props => { + await createRootComponent(this, components.ComposerSuggestion, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('command suggestion displayed', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const command = this.env.models['mail.channel_command'].create({ + name: 'whois', + help: "Displays who it is", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.channel_command', + recordLocalId: command.localId, + }); + + assert.containsOnce( + document.body, + `.o_ComposerSuggestion`, + "Command suggestion should be present" + ); +}); + +QUnit.test('command suggestion correct data', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const command = this.env.models['mail.channel_command'].create({ + name: 'whois', + help: "Displays who it is", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.channel_command', + recordLocalId: command.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Command suggestion should be present" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part1', + "Command name should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part1`).textContent, + "whois", + "Command name should be displayed" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part2', + "Command help should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part2`).textContent, + "Displays who it is", + "Command help should be displayed" + ); +}); + +QUnit.test('command suggestion active', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const command = this.env.models['mail.channel_command'].create({ + name: 'whois', + help: "Displays who it is", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.channel_command', + recordLocalId: command.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Command suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_ComposerSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js new file mode 100644 index 00000000..548fd6d7 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js @@ -0,0 +1,160 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer_suggestion', {}, function () { +QUnit.module('composer_suggestion_partner_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerSuggestion = async props => { + await createRootComponent(this, components.ComposerSuggestion, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('partner mention suggestion displayed', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const partner = this.env.models['mail.partner'].create({ + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.partner', + recordLocalId: partner.localId, + }); + + assert.containsOnce( + document.body, + `.o_ComposerSuggestion`, + "Partner mention suggestion should be present" + ); +}); + +QUnit.test('partner mention suggestion correct data', async function (assert) { + assert.expect(6); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const partner = this.env.models['mail.partner'].create({ + email: "demo_user@odoo.com", + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.partner', + recordLocalId: partner.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Partner mention suggestion should be present" + ); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon`).length, + 1, + "Partner's im_status should be displayed" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part1', + "Partner's name should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part1`).textContent, + "Demo User", + "Partner's name should be displayed" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part2', + "Partner's email should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part2`).textContent, + "(demo_user@odoo.com)", + "Partner's email should be displayed" + ); +}); + +QUnit.test('partner mention suggestion active', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const partner = this.env.models['mail.partner'].create({ + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.partner', + recordLocalId: partner.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Partner mention suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_ComposerSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js new file mode 100644 index 00000000..23f08399 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js @@ -0,0 +1,73 @@ +odoo.define('mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ComposerSuggestionList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + const activeSuggestedRecord = composer + ? composer.activeSuggestedRecord + : undefined; + const extraSuggestedRecords = composer + ? composer.extraSuggestedRecords + : []; + const mainSuggestedRecords = composer + ? composer.mainSuggestedRecords + : []; + return { + activeSuggestedRecord, + composer, + composerSuggestionModelName: composer && composer.suggestionModelName, + extraSuggestedRecords, + mainSuggestedRecords, + }; + }, { + compareDepth: { + extraSuggestedRecords: 1, + mainSuggestedRecords: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + +} + +Object.assign(ComposerSuggestionList, { + components, + defaultProps: { + isBelow: false, + }, + props: { + composerLocalId: String, + isBelow: Boolean, + }, + template: 'mail.ComposerSuggestionList', +}); + +return ComposerSuggestionList; + +}); diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss new file mode 100644 index 00000000..fa8d375e --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ComposerSuggestionList { + position: absolute; + // prevent suggestion items from overflowing + width: 100%; + + &.o-lowPosition { + bottom: 0; + } +} + +.o_ComposerSuggestionList_drop { + // prevent suggestion items from overflowing + width: 100%; +} + +.o_ComposerSuggestionList_list { + // prevent suggestion items from overflowing + width: 100%; + // prevent from overflowing chat window, must be smaller than its height + // minus the max height taken by composer and attachment list + max-height: 150px; + overflow: auto; +} diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml new file mode 100644 index 00000000..9747109a --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ComposerSuggestionList" owl="1"> + <div class="o_ComposerSuggestionList" t-att-class="{ 'o-lowPosition': props.isBelow }"> + <div class="o_ComposerSuggestionList_drop" t-att-class="{ 'dropdown': props.isBelow, 'dropup': !props.isBelow }"> + <div class="o_ComposerSuggestionList_list dropdown-menu show"> + <t t-foreach="composer.mainSuggestedRecords" t-as="record" t-key="record.localId"> + <ComposerSuggestion + composerLocalId="props.composerLocalId" + isActive="record === composer.activeSuggestedRecord" + modelName="composer.suggestionModelName" + recordLocalId="record.localId" + /> + </t> + <t t-if="composer.mainSuggestedRecords.length > 0 and composer.extraSuggestedRecords.length > 0"> + <div role="separator" class="dropdown-divider"/> + </t> + <t t-foreach="composer.extraSuggestedRecords" t-as="record" t-key="record.localId"> + <ComposerSuggestion + composerLocalId="props.composerLocalId" + isActive="record === composer.activeSuggestedRecord" + modelName="composer.suggestionModelName" + recordLocalId="record.localId" + /> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js new file mode 100644 index 00000000..2bdd34da --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js @@ -0,0 +1,419 @@ +odoo.define('mail/static/src/components/composer_text_input/composer_text_input.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const components = { + ComposerSuggestionList: require('mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js'), +}; +const { markEventHandled } = require('mail/static/src/utils/utils.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ComposerTextInput extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + sendShortcuts: 1, + }, + }); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + const thread = composer && composer.thread; + return { + composerHasFocus: composer && composer.hasFocus, + composerHasSuggestions: composer && composer.hasSuggestions, + composerIsLog: composer && composer.isLog, + composerTextInputContent: composer && composer.textInputContent, + composerTextInputCursorEnd: composer && composer.textInputCursorEnd, + composerTextInputCursorStart: composer && composer.textInputCursorStart, + composerTextInputSelectionDirection: composer && composer.textInputSelectionDirection, + isDeviceMobile: this.env.messaging.device.isMobile, + threadModel: thread && thread.model, + }; + }); + /** + * Updates the composer text input content when composer is mounted + * as textarea content can't be changed from the DOM. + */ + useUpdate({ func: () => this._update() }); + /** + * Last content of textarea from input event. Useful to determine + * whether the current partner is typing something. + */ + this._textareaLastInputValue = ""; + /** + * Reference of the textarea. Useful to set height, selection and content. + */ + this._textareaRef = useRef('textarea'); + /** + * This is the invisible textarea used to compute the composer height + * based on the text content. We need it to downsize the textarea + * properly without flicker. + */ + this._mirroredTextareaRef = useRef('mirroredTextarea'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + /** + * @returns {string} + */ + get textareaPlaceholder() { + if (!this.composer) { + return ""; + } + if (this.composer.thread && this.composer.thread.model !== 'mail.channel') { + if (this.composer.isLog) { + return this.env._t("Log an internal note..."); + } + return this.env._t("Send a message to followers..."); + } + return this.env._t("Write something..."); + } + + focus() { + this._textareaRef.el.focus(); + } + + focusout() { + this.saveStateInStore(); + this._textareaRef.el.blur(); + } + + /** + * Saves the composer text input state in store + */ + saveStateInStore() { + this.composer.update({ + textInputContent: this._getContent(), + textInputCursorEnd: this._getSelectionEnd(), + textInputCursorStart: this._getSelectionStart(), + textInputSelectionDirection: this._textareaRef.el.selectionDirection, + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns textarea current content. + * + * @private + * @returns {string} + */ + _getContent() { + return this._textareaRef.el.value; + } + + /** + * Returns selection end position. + * + * @private + * @returns {integer} + */ + _getSelectionEnd() { + return this._textareaRef.el.selectionEnd; + } + + /** + * Returns selection start position. + * + * @private + * @returns {integer} + * + */ + _getSelectionStart() { + return this._textareaRef.el.selectionStart; + } + + /** + * Determines whether the textarea is empty or not. + * + * @private + * @returns {boolean} + */ + _isEmpty() { + return this._getContent() === ""; + } + + /** + * Updates the content and height of a textarea + * + * @private + */ + _update() { + if (!this.composer) { + return; + } + if (this.composer.isLastStateChangeProgrammatic) { + this._textareaRef.el.value = this.composer.textInputContent; + if (this.composer.hasFocus) { + this._textareaRef.el.setSelectionRange( + this.composer.textInputCursorStart, + this.composer.textInputCursorEnd, + this.composer.textInputSelectionDirection, + ); + } + this.composer.update({ isLastStateChangeProgrammatic: false }); + } + this._updateHeight(); + } + + /** + * Updates the textarea height. + * + * @private + */ + _updateHeight() { + this._mirroredTextareaRef.el.value = this.composer.textInputContent; + this._textareaRef.el.style.height = (this._mirroredTextareaRef.el.scrollHeight) + "px"; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickTextarea() { + // clicking might change the cursor position + this.saveStateInStore(); + } + + /** + * @private + */ + _onFocusinTextarea() { + this.composer.focus(); + this.trigger('o-focusin-composer'); + } + + /** + * @private + */ + _onFocusoutTextarea() { + this.saveStateInStore(); + this.composer.update({ hasFocus: false }); + } + + /** + * @private + */ + _onInputTextarea() { + this.saveStateInStore(); + if (this._textareaLastInputValue !== this._textareaRef.el.value) { + this.composer.handleCurrentPartnerIsTyping(); + } + this._textareaLastInputValue = this._textareaRef.el.value; + this._updateHeight(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownTextarea(ev) { + switch (ev.key) { + case 'Escape': + if (this.composer.hasSuggestions) { + ev.preventDefault(); + this.composer.closeSuggestions(); + markEventHandled(ev, 'ComposerTextInput.closeSuggestions'); + } + break; + // UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions + case 'ArrowUp': + case 'PageUp': + case 'ArrowDown': + case 'PageDown': + case 'Home': + case 'End': + case 'Tab': + if (this.composer.hasSuggestions) { + // We use preventDefault here to avoid keys native actions but actions are handled in keyUp + ev.preventDefault(); + } + break; + // ENTER: submit the message only if the dropdown mention proposition is not displayed + case 'Enter': + this._onKeydownTextareaEnter(ev); + break; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownTextareaEnter(ev) { + if (this.composer.hasSuggestions) { + ev.preventDefault(); + return; + } + if ( + this.props.sendShortcuts.includes('ctrl-enter') && + !ev.altKey && + ev.ctrlKey && + !ev.metaKey && + !ev.shiftKey + ) { + this.trigger('o-composer-text-input-send-shortcut'); + ev.preventDefault(); + return; + } + if ( + this.props.sendShortcuts.includes('enter') && + !ev.altKey && + !ev.ctrlKey && + !ev.metaKey && + !ev.shiftKey + ) { + this.trigger('o-composer-text-input-send-shortcut'); + ev.preventDefault(); + return; + } + if ( + this.props.sendShortcuts.includes('meta-enter') && + !ev.altKey && + !ev.ctrlKey && + ev.metaKey && + !ev.shiftKey + ) { + this.trigger('o-composer-text-input-send-shortcut'); + ev.preventDefault(); + return; + } + } + + /** + * Key events management is performed in a Keyup to avoid intempestive RPC calls + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeyupTextarea(ev) { + switch (ev.key) { + case 'Escape': + // already handled in _onKeydownTextarea, break to avoid default + break; + // ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions + case 'Enter': + if (this.composer.hasSuggestions) { + this.composer.insertSuggestion(); + this.composer.closeSuggestions(); + this.focus(); + } + break; + case 'ArrowUp': + case 'PageUp': + if (this.composer.hasSuggestions) { + this.composer.setPreviousSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + break; + case 'ArrowDown': + case 'PageDown': + if (this.composer.hasSuggestions) { + this.composer.setNextSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + break; + case 'Home': + if (this.composer.hasSuggestions) { + this.composer.setFirstSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + break; + case 'End': + if (this.composer.hasSuggestions) { + this.composer.setLastSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + break; + case 'Tab': + if (this.composer.hasSuggestions) { + if (ev.shiftKey) { + this.composer.setPreviousSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } else { + this.composer.setNextSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + } + break; + case 'Alt': + case 'AltGraph': + case 'CapsLock': + case 'Control': + case 'Fn': + case 'FnLock': + case 'Hyper': + case 'Meta': + case 'NumLock': + case 'ScrollLock': + case 'Shift': + case 'ShiftSuper': + case 'Symbol': + case 'SymbolLock': + // prevent modifier keys from resetting the suggestion state + break; + // Otherwise, check if a mention is typed + default: + this.saveStateInStore(); + } + } + +} + +Object.assign(ComposerTextInput, { + components, + defaultProps: { + hasMentionSuggestionsBelowPosition: false, + sendShortcuts: [], + }, + props: { + composerLocalId: String, + hasMentionSuggestionsBelowPosition: Boolean, + isCompact: Boolean, + /** + * Keyboard shortcuts from text input to send message. + */ + sendShortcuts: { + type: Array, + element: String, + validate: prop => { + for (const shortcut of prop) { + if (!['ctrl-enter', 'enter', 'meta-enter'].includes(shortcut)) { + return false; + } + } + return true; + }, + }, + }, + template: 'mail.ComposerTextInput', +}); + +return ComposerTextInput; + +}); diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.scss b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss new file mode 100644 index 00000000..b9119a71 --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ComposerTextInput { + min-width: 0; + position: relative; +} + +.o_ComposerTextInput_mirroredTextarea { + height: 0; + position: absolute; + opacity: 0; + overflow: hidden; + top: -10000px; +} + +.o_ComposerTextInput_textareaStyle { + padding: 10px; + resize: none; + border-radius: $o-mail-rounded-rectangle-border-radius-lg; + border: none; + overflow: auto; + + &.o-composer-is-compact { + // When composer is compact, textarea should not be rounded on the right as + // buttons are glued to it + border-top-right-radius: 0; + border-bottom-right-radius: 0; + // Chat window height should be taken into account to choose this value + // ideally this should be less than the third of chat window height + max-height: 100px; + } + + &:not(.o-composer-is-compact) { + // Don't allow the input to take the whole height when it's not compact + // (like in chatter for example) but allow it to take some more place + max-height: 400px; + } +} diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml new file mode 100644 index 00000000..a14fdee8 --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ComposerTextInput" owl="1"> + <div class="o_ComposerTextInput"> + <t t-if="composer"> + <t t-if="composer.hasSuggestions"> + <ComposerSuggestionList + composerLocalId="props.composerLocalId" + isBelow="props.hasMentionSuggestionsBelowPosition" + /> + </t> + <textarea class="o_ComposerTextInput_textarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-att-placeholder="textareaPlaceholder" t-on-click="_onClickTextarea" t-on-focusin="_onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/> + <!-- + This is an invisible textarea used to compute the composer + height based on the text content. We need it to downsize + the textarea properly without flicker. + --> + <textarea class="o_ComposerTextInput_mirroredTextarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-ref="mirroredTextarea" disabled="1"/> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/dialog/dialog.js b/addons/mail/static/src/components/dialog/dialog.js new file mode 100644 index 00000000..65902c9c --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.js @@ -0,0 +1,119 @@ +odoo.define('mail/static/src/components/dialog/dialog.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const patchMixin = require('web.patchMixin'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Dialog extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + /** + * Reference to the component used inside this dialog. + */ + this._componentRef = useRef('component'); + this._onClickGlobal = this._onClickGlobal.bind(this); + this._onKeydownDocument = this._onKeydownDocument.bind(this); + useShouldUpdateBasedOnProps(); + useStore(props => { + const dialog = this.env.models['mail.dialog'].get(props.dialogLocalId); + return { + dialog: dialog ? dialog.__state : undefined, + }; + }); + this._constructor(); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + mounted() { + document.addEventListener('click', this._onClickGlobal, true); + document.addEventListener('keydown', this._onKeydownDocument); + } + + willUnmount() { + document.removeEventListener('click', this._onClickGlobal, true); + document.removeEventListener('keydown', this._onKeydownDocument); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.dialog} + */ + get dialog() { + return this.env.models['mail.dialog'].get(this.props.dialogLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on this dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + ev.stopPropagation(); + } + + /** + * Closes the dialog when clicking outside. + * Does not work with attachment viewer because it takes the whole space. + * + * @private + * @param {MouseEvent} ev + */ + _onClickGlobal(ev) { + if (this._componentRef.el && this._componentRef.el.contains(ev.target)) { + return; + } + // TODO: this should be child logic (will crash if child doesn't have isCloseable!!) + // task-2092965 + if ( + this._componentRef.comp && + this._componentRef.comp.isCloseable && + !this._componentRef.comp.isCloseable() + ) { + return; + } + this.dialog.delete(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownDocument(ev) { + if (ev.key === 'Escape') { + this.dialog.delete(); + } + } + +} + +Object.assign(Dialog, { + props: { + dialogLocalId: String, + }, + template: 'mail.Dialog', +}); + +return patchMixin(Dialog); + +}); diff --git a/addons/mail/static/src/components/dialog/dialog.scss b/addons/mail/static/src/components/dialog/dialog.scss new file mode 100644 index 00000000..fa17dac0 --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.scss @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Dialog { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: $zindex-modal; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Dialog { + background-color: rgba(0, 0, 0, 0.7); +} diff --git a/addons/mail/static/src/components/dialog/dialog.xml b/addons/mail/static/src/components/dialog/dialog.xml new file mode 100644 index 00000000..3c953dec --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Dialog" owl="1"> + <div class="o_Dialog"> + <t t-if="dialog"> + <t t-if="dialog.record"> + <t + t-component="{{ dialog.record['constructor'].name }}" + class="o_Dialog_component" + t-props="{ localId: dialog.record.localId }" + t-ref="component" + /> + </t> + <t t-else=""> + <span>Only dialog linked to a record is currently supported!</span> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.js b/addons/mail/static/src/components/dialog_manager/dialog_manager.js new file mode 100644 index 00000000..69b64a27 --- /dev/null +++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.js @@ -0,0 +1,69 @@ +odoo.define('mail/static/src/components/dialog_manager/dialog_manager.js', function (require) { +'use strict'; + +const components = { + Dialog: require('mail/static/src/components/dialog/dialog.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class DialogManager extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const dialogManager = this.env.messaging && this.env.messaging.dialogManager; + return { + dialogManager: dialogManager ? dialogManager.__state : undefined, + }; + }); + } + + mounted() { + this._checkDialogOpen(); + } + + patched() { + this._checkDialogOpen(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _checkDialogOpen() { + if (!this.env.messaging) { + /** + * Messaging not created, which means essential models like + * dialog manager are not ready, so open status of dialog in DOM + * is omitted during this (short) period of time. + */ + return; + } + if (this.env.messaging.dialogManager.dialogs.length > 0) { + document.body.classList.add('modal-open'); + } else { + document.body.classList.remove('modal-open'); + } + } + +} + +Object.assign(DialogManager, { + components, + props: {}, + template: 'mail.DialogManager', +}); + +return DialogManager; + +}); diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.xml b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml new file mode 100644 index 00000000..035e543e --- /dev/null +++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DialogManager" owl="1"> + <div class="o_DialogManager"> + <t t-if="env.messaging"> + <t t-foreach="env.messaging.dialogManager.dialogs" t-as="dialog" t-key="dialog.localId"> + <Dialog + class="o_DialogManager_dialog" + dialogLocalId="dialog.localId" + /> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager_tests.js b/addons/mail/static/src/components/dialog_manager/dialog_manager_tests.js new file mode 100644 index 00000000..f377ec17 --- /dev/null +++ b/addons/mail/static/src/components/dialog_manager/dialog_manager_tests.js @@ -0,0 +1,82 @@ +odoo.define('mail/static/src/components/dialog_manager/dialog_manager_tests.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { + afterEach, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('dialog_manager', {}, function () { +QUnit.module('dialog_manager_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign( + { hasDialog: true }, + params, + { data: this.data } + )); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('[technical] messaging not created', async function (assert) { + /** + * Creation of messaging in env is async due to generation of models being + * async. Generation of models is async because it requires parsing of all + * JS modules that contain pieces of model definitions. + * + * Time of having no messaging is very short, almost imperceptible by user + * on UI, but the display should not crash during this critical time period. + */ + assert.expect(2); + + const messagingBeforeCreationDeferred = makeDeferred(); + await this.start({ + messagingBeforeCreationDeferred, + waitUntilMessagingCondition: 'none', + }); + assert.containsOnce( + document.body, + '.o_DialogManager', + "should have dialog manager even when messaging is not yet created" + ); + + // simulate messaging being created + messagingBeforeCreationDeferred.resolve(); + await nextAnimationFrame(); + + assert.containsOnce( + document.body, + '.o_DialogManager', + "should still contain dialog manager after messaging has been created" + ); +}); + +QUnit.test('initial mount', async function (assert) { + assert.expect(1); + + await this.start(); + assert.containsOnce( + document.body, + '.o_DialogManager', + "should have dialog manager" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss/discuss.js b/addons/mail/static/src/components/discuss/discuss.js new file mode 100644 index 00000000..068fd88d --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.js @@ -0,0 +1,313 @@ +odoo.define('mail/static/src/components/discuss/discuss.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + Composer: require('mail/static/src/components/composer/composer.js'), + DiscussMobileMailboxSelection: require('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js'), + DiscussSidebar: require('mail/static/src/components/discuss_sidebar/discuss_sidebar.js'), + MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'), + ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'), + ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'), + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), + ThreadView: require('mail/static/src/components/thread_view/thread_view.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const patchMixin = require('web.patchMixin'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Discuss extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore((...args) => this._useStoreSelector(...args), { + compareDepth: { + checkedMessages: 1, + uncheckedMessages: 1, + }, + }); + this._updateLocalStoreProps(); + /** + * Reference of the composer. Useful to focus it. + */ + this._composerRef = useRef('composer'); + /** + * Reference of the ThreadView. Useful to focus it. + */ + this._threadViewRef = useRef('threadView'); + // bind since passed as props + this._onMobileAddItemHeaderInputSelect = this._onMobileAddItemHeaderInputSelect.bind(this); + this._onMobileAddItemHeaderInputSource = this._onMobileAddItemHeaderInputSource.bind(this); + } + + mounted() { + this.discuss.update({ isOpen: true }); + if (this.discuss.thread) { + this.trigger('o-push-state-action-manager'); + } else if (this.env.isMessagingInitialized()) { + this.discuss.openInitThread(); + } + this._updateLocalStoreProps(); + } + + patched() { + this.trigger('o-update-control-panel'); + if (this.discuss.thread) { + this.trigger('o-push-state-action-manager'); + } + if ( + this.discuss.thread && + this.discuss.thread === this.env.messaging.inbox && + this.discuss.threadView && + this._lastThreadCache === this.discuss.threadView.threadCache.localId && + this._lastThreadCounter > 0 && this.discuss.thread.counter === 0 + ) { + this.trigger('o-show-rainbow-man'); + } + this._activeThreadCache = this.discuss.threadView && this.discuss.threadView.threadCache; + this._updateLocalStoreProps(); + } + + willUnmount() { + if (this.discuss) { + this.discuss.close(); + } + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get addChannelInputPlaceholder() { + return this.env._t("Create or search channel..."); + } + + /** + * @returns {string} + */ + get addChatInputPlaceholder() { + return this.env._t("Search user..."); + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {Object[]} + */ + mobileNavbarTabs() { + return [{ + icon: 'fa fa-inbox', + id: 'mailbox', + label: this.env._t("Mailboxes"), + }, { + icon: 'fa fa-user', + id: 'chat', + label: this.env._t("Chat"), + }, { + icon: 'fa fa-users', + id: 'channel', + label: this.env._t("Channel"), + }]; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _updateLocalStoreProps() { + /** + * Locally tracked store props `activeThreadCache`. + * Useful to set scroll position from last stored one and to display + * rainbox man on inbox. + */ + this._lastThreadCache = ( + this.discuss.threadView && + this.discuss.threadView.threadCache && + this.discuss.threadView.threadCache.localId + ); + /** + * Locally tracked store props `threadCounter`. + * Useful to display the rainbow man on inbox. + */ + this._lastThreadCounter = ( + this.discuss.thread && + this.discuss.thread.counter + ); + } + + /** + * Returns data selected from the store. + * + * @private + * @param {Object} props + * @returns {Object} + */ + _useStoreSelector(props) { + const discuss = this.env.messaging && this.env.messaging.discuss; + const thread = discuss && discuss.thread; + const threadView = discuss && discuss.threadView; + const replyingToMessage = discuss && discuss.replyingToMessage; + const replyingToMessageOriginThread = replyingToMessage && replyingToMessage.originThread; + const checkedMessages = threadView ? threadView.checkedMessages : []; + return { + checkedMessages, + checkedMessagesIsModeratedByCurrentPartner: checkedMessages && checkedMessages.some(message => message.isModeratedByCurrentPartner), // for widget + discuss, + discussActiveId: discuss && discuss.activeId, // for widget + discussActiveMobileNavbarTabId: discuss && discuss.activeMobileNavbarTabId, + discussHasModerationDiscardDialog: discuss && discuss.hasModerationDiscardDialog, + discussHasModerationRejectDialog: discuss && discuss.hasModerationRejectDialog, + discussIsAddingChannel: discuss && discuss.isAddingChannel, + discussIsAddingChat: discuss && discuss.isAddingChat, + discussIsDoFocus: discuss && discuss.isDoFocus, + discussReplyingToMessageOriginThreadComposer: replyingToMessageOriginThread && replyingToMessageOriginThread.composer, + inbox: this.env.messaging.inbox, + isDeviceMobile: this.env.messaging && this.env.messaging.device.isMobile, + isMessagingInitialized: this.env.isMessagingInitialized(), + replyingToMessage, + starred: this.env.messaging.starred, // for widget + thread, + threadCache: threadView && threadView.threadCache, + threadChannelType: thread && thread.channel_type, // for widget + threadDisplayName: thread && thread.displayName, // for widget + threadCounter: thread && thread.counter, + threadModel: thread && thread.model, + threadPublic: thread && thread.public, // for widget + threadView, + threadViewMessagesLength: threadView && threadView.messages.length, // for widget + uncheckedMessages: threadView ? threadView.uncheckedMessages : [], // for widget + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onDialogClosedModerationDiscard() { + this.discuss.update({ hasModerationDiscardDialog: false }); + } + + /** + * @private + */ + _onDialogClosedModerationReject() { + this.discuss.update({ hasModerationRejectDialog: false }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onFocusinComposer(ev) { + this.discuss.update({ isDoFocus: false }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideMobileAddItemHeader(ev) { + ev.stopPropagation(); + this.discuss.clearIsAddingItem(); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onMobileAddItemHeaderInputSelect(ev, ui) { + const discuss = this.discuss; + if (discuss.isAddingChannel) { + discuss.handleAddChannelAutocompleteSelect(ev, ui); + } else { + discuss.handleAddChatAutocompleteSelect(ev, ui); + } + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onMobileAddItemHeaderInputSource(req, res) { + if (this.discuss.isAddingChannel) { + this.discuss.handleAddChannelAutocompleteSource(req, res); + } else { + this.discuss.handleAddChatAutocompleteSource(req, res); + } + } + + /** + * @private + */ + _onReplyingToMessageMessagePosted() { + this.env.services['notification'].notify({ + message: _.str.sprintf( + this.env._t(`Message posted on "%s"`), + owl.utils.escape(this.discuss.replyingToMessage.originThread.displayName) + ), + type: 'warning', + }); + this.discuss.clearReplyingToMessage(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.tabId + */ + _onSelectMobileNavbarTab(ev) { + ev.stopPropagation(); + if (this.discuss.activeMobileNavbarTabId === ev.detail.tabId) { + return; + } + this.discuss.clearReplyingToMessage(); + this.discuss.update({ activeMobileNavbarTabId: ev.detail.tabId }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onThreadRendered(ev) { + this.trigger('o-update-control-panel'); + } + +} + +Object.assign(Discuss, { + components, + props: {}, + template: 'mail.Discuss', +}); + +return patchMixin(Discuss); + +}); diff --git a/addons/mail/static/src/components/discuss/discuss.scss b/addons/mail/static/src/components/discuss/discuss.scss new file mode 100644 index 00000000..5cddf101 --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.scss @@ -0,0 +1,114 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_action_manager { + // bug with scrollable inside discuss mobile without this... + min-height: 0; +} + +.o-autogrow { + flex: 1 1 auto; +} + +.o_Discuss { + display: flex; + height: 100%; + min-height: 0; + + &.o-mobile { + flex-flow: column; + align-items: center; + } +} + +.o_Discuss_chatWindowHeader { + width: 100%; + flex: 0 0 auto; +} + +.o_Discuss_content { + height: 100%; + overflow: auto; + flex: 1 1 auto; + display: flex; + flex-flow: column; +} + +.o_Discuss_messagingNotInitialized { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.o_Discuss_messagingNotInitializedIcon { + margin-right: 3px; +} + +.o_Discuss_mobileAddItemHeader { + display: flex; + justify-content: center; + width: 100%; + padding: 0 10px; +} + +.o_Discuss_mobileAddItemHeaderInput { + flex: 1 1 auto; + margin-bottom: 8px; + padding: 8px; +} + +.o_Discuss_mobileMailboxSelection { + width: 100%; +} + +.o_Discuss_mobileNavbar { + width: 100%; +} + +.o_Discuss_noThread { + display: flex; + flex: 1 1 auto; + width: 100%; + align-items: center; + justify-content: center; +} + +.o_Discuss_replyingToMessageComposer { + width: 100%; +} + +.o_Discuss_sidebar { + height: 100%; + overflow: auto; + padding-top: 10px; + flex: 0 0 auto; +} + +.o_Discuss_thread { + flex: 1 1 auto; + + &.o-mobile { + width: 100%; + } +} + +.o_Discuss_notificationList { + width: 100%; + flex: 1 1 auto; +} +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Discuss.o-mobile { + background-color: white; +} + +.o_Discuss_mobileAddItemHeaderInput { + appearance: none; + border: 1px solid gray('400'); + border-radius: 5px; + outline: none; +} diff --git a/addons/mail/static/src/components/discuss/discuss.xml b/addons/mail/static/src/components/discuss/discuss.xml new file mode 100644 index 00000000..ad9171a5 --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.xml @@ -0,0 +1,106 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Discuss" owl="1"> + <div class="o_Discuss" + t-att-class="{ + 'o-adding-item': discuss ? discuss.isAddingChannel or discuss.isAddingChat : false, + 'o-mobile': env.messaging ? env.messaging.device.isMobile : false, + }" + > + <t t-if="!env.isMessagingInitialized()"> + <div class="o_Discuss_messagingNotInitialized"><i class="o_Discuss_messagingNotInitializedIcon fa fa-spinner fa-spin"/>Please wait...</div> + </t> + <t t-else=""> + <t t-if="!env.messaging.device.isMobile"> + <DiscussSidebar class="o_Discuss_sidebar"/> + </t> + <t t-if="env.messaging.device.isMobile" t-call="mail.Discuss.content"/> + <t t-else=""> + <div class="o_Discuss_content"> + <t t-call="mail.Discuss.content"/> + </div> + </t> + <t t-if="discuss.hasModerationDiscardDialog"> + <ModerationDiscardDialog messageLocalIds="discuss.threadView.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationDiscard"/> + </t> + <t t-if="discuss.hasModerationRejectDialog"> + <ModerationRejectDialog messageLocalIds="discuss.threadView.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationReject"/> + </t> + </t> + </div> + </t> + + <t t-name="mail.Discuss.content" owl="1"> + <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox'"> + <DiscussMobileMailboxSelection class="o_Discuss_mobileMailboxSelection"/> + </t> + <t t-if="env.messaging.device.isMobile and (discuss.isAddingChannel or discuss.isAddingChat)"> + <div class="o_Discuss_mobileAddItemHeader"> + <AutocompleteInput + class="o_Discuss_mobileAddItemHeaderInput" + isFocusOnMount="true" + isHtml="discuss.isAddingChannel" + placeholder="discuss.isAddingChannel ? addChannelInputPlaceholder : addChatInputPlaceholder" + select="_onMobileAddItemHeaderInputSelect" + source="_onMobileAddItemHeaderInputSource" + t-on-o-hide="_onHideMobileAddItemHeader" + /> + </div> + </t> + <t t-if="discuss.threadView"> + <ThreadView + class="o_Discuss_thread" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + composerAttachmentsDetailsMode="'card'" + hasComposer="discuss.thread.model !== 'mail.box'" + hasComposerCurrentPartnerAvatar="!env.messaging.device.isMobile" + hasComposerThreadTyping="true" + hasMessageCheckbox="true" + hasSquashCloseMessages="discuss.thread.model !== 'mail.box'" + haveMessagesMarkAsReadIcon="discuss.thread === env.messaging.inbox" + haveMessagesReplyIcon="discuss.thread === env.messaging.inbox" + isDoFocus="discuss.isDoFocus" + selectedMessageLocalId="discuss.replyingToMessage and discuss.replyingToMessage.localId" + threadViewLocalId="discuss.threadView.localId" + t-on-o-focusin-composer="_onFocusinComposer" + t-on-o-rendered="_onThreadRendered" + t-ref="threadView" + /> + </t> + <t t-if="!discuss.thread and (!env.messaging.device.isMobile or discuss.activeMobileNavbarTabId === 'mailbox')"> + <div class="o_Discuss_noThread"> + No conversation selected. + </div> + </t> + <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId !== 'mailbox'"> + <NotificationList + class="o_Discuss_notificationList" + filter="discuss.activeMobileNavbarTabId" + /> + </t> + <t t-if="env.messaging.device.isMobile and !discuss.isReplyingToMessage"> + <MobileMessagingNavbar + class="o_Discuss_mobileNavbar" + activeTabId="discuss.activeMobileNavbarTabId" + tabs="mobileNavbarTabs()" + t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab" + /> + </t> + <t t-if="discuss.isReplyingToMessage"> + <Composer + class="o_Discuss_replyingToMessageComposer" + composerLocalId="discuss.replyingToMessage.originThread.composer.localId" + hasCurrentPartnerAvatar="!env.messaging.device.isMobile" + hasDiscardButton="true" + hasThreadName="true" + isDoFocus="discuss.isDoFocus" + textInputSendShortcuts="['ctrl-enter', 'meta-enter']" + t-on-o-focusin-composer="_onFocusinComposer" + t-on-o-message-posted="_onReplyingToMessageMessagePosted" + t-ref="composer" + /> + </t> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js new file mode 100644 index 00000000..26431207 --- /dev/null +++ b/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js @@ -0,0 +1,408 @@ +odoo.define('mail/static/src/components/discuss/tests/discuss_domain_tests.js', function (require) { +'use strict'; + +const { + afterEach, + afterNextRender, + beforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_domain_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { afterEvent, env, widget } = await start(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.afterEvent = afterEvent; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('discuss should filter messages based on given domain', async function (assert) { + assert.expect(2); + + this.data['mail.message'].records.push({ + body: "test", + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + }, { + body: "not empty", + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + }); + await this.start(); + assert.containsN( + document.body, + '.o_Message', + 2, + "should have 2 messages in Inbox initially" + ); + + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + // simulate control panel search + this.env.messaging.discuss.update({ + stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]), + }); + }, + message: "should wait until search filter is applied", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + hint.data.fetchedMessages.length === 1 && + threadViewer.thread.model === 'mail.box' && + threadViewer.thread.id === 'inbox' + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should only have the 1 message containing 'test' remaining after doing a search" + ); +}); + +QUnit.test('discuss should keep filter domain on changing thread', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.message'].records.push({ + body: "test", + channel_ids: [20], + }, { + body: "not empty", + channel_ids: [20], + }); + await this.start(); + const channel = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + assert.containsNone( + document.body, + '.o_Message', + "should have no message in Inbox initially" + ); + + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + // simulate control panel search + this.env.messaging.discuss.update({ + stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]), + }); + }, + message: "should wait until search filter is applied", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.box' && + threadViewer.thread.id === 'inbox' + ); + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should have still no message in Inbox after doing a search" + ); + + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${channel.localId}"] + `).click(); + }, + message: "should wait until channel 20 is loaded after clicking on it", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should only have the 1 message containing 'test' in channel 20 (due to the domain still applied on changing thread)" + ); +}); + +QUnit.test('discuss should refresh filtered thread on receiving new message', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const channel = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${channel.localId}"] + `).click(); + }, + message: "should wait until channel 20 is loaded after clicking on it", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + // simulate control panel search + this.env.messaging.discuss.update({ + stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]), + }); + }, + message: "should wait until search filter is applied", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should have initially no message in channel 20 matching the search 'test'" + ); + + // simulate receiving a message + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + message_content: "test", + uuid: channel.uuid, + }, + }), + message: "should wait until channel 20 refreshed its filtered message list", + predicate: data => { + return ( + data.threadViewer.thread.model === 'mail.channel' && + data.threadViewer.thread.id === 20 && + data.hint.type === 'messages-loaded' + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should only have the 1 message containing 'test' in channel 20 after just receiving it" + ); +}); + +QUnit.test('discuss should refresh filtered thread on changing thread', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ id: 20 }, { id: 21 }); + await this.start(); + const channel20 = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const channel21 = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 21, + model: 'mail.channel', + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${channel20.localId}"] + `).click(); + }, + message: "should wait until channel 20 is loaded after clicking on it", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + // simulate control panel search + this.env.messaging.discuss.update({ + stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]), + }); + }, + message: "should wait until search filter is applied", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should have initially no message in channel 20 matching the search 'test'" + ); + + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${channel21.localId}"] + `).click(); + }, + message: "should wait until channel 21 is loaded after clicking on it", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 21 + ); + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should have no message in channel 21 matching the search 'test'" + ); + // simulate receiving a message on channel 20 while channel 21 is displayed + await this.env.services.rpc({ + route: '/mail/chat_post', + params: { + message_content: "test", + uuid: channel20.uuid, + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should still have no message in channel 21 matching the search 'test' after receiving a message on channel 20" + ); + + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${channel20.localId}"] + `).click(); + }, + message: "should wait until channel 20 is loaded with the new message after clicking on it", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + threadViewer.threadCache.fetchedMessages.length === 1 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should now have the 1 message containing 'test' in channel 20 when displaying it, after having received the message while the channel was not visible" + ); +}); + +QUnit.test('select all and unselect all buttons should work on filtered thread', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ + id: 20, + is_moderator: true, + moderation: true, + name: "general", + }); + this.data['mail.message'].records.push({ + body: "<p>test</p>", + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + }); + await this.start(); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${this.env.messaging.moderation.localId}"] + `).click(); + }, + message: "should wait until moderation box is loaded after clicking on it", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.box' && + threadViewer.thread.id === 'moderation' + ); + }, + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + // simulate control panel search + this.env.messaging.discuss.update({ + stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]), + }); + }, + message: "should wait until search filter is applied", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.box' && + threadViewer.thread.id === 'moderation' + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should only have the 1 message containing 'test' in moderation box" + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should not be checked initially" + ); + + await afterNextRender(() => document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click()); + assert.ok( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should be checked after clicking on 'select all'" + ); + + await afterNextRender(() => document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click()); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should be unchecked after clicking on 'unselect all'" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js new file mode 100644 index 00000000..63b524eb --- /dev/null +++ b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js @@ -0,0 +1,725 @@ +odoo.define('mail/static/src/components/discuss/tests/discuss_inbox_tests.js', function (require) { +'use strict'; + +const { + afterEach, + afterNextRender, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_inbox_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('reply: discard on pressing escape', async function (assert) { + assert.expect(9); + + // partner expected to be found by mention + this.data['res.partner'].records.push({ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }); + // message expected to be found in inbox + this.data['mail.message'].records.push({ + body: "not empty", + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 20, + }); + await this.start(); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.containsOnce( + document.body, + '.o_Composer', + "should have composer after clicking on reply to message" + ); + + await afterNextRender(() => + document.querySelector(`.o_Composer_buttonEmojis`).click() + ); + assert.containsOnce( + document.body, + '.o_EmojisPopover', + "emojis popover should be opened after click on emojis button" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_Composer_buttonEmojis`).dispatchEvent(ev); + }); + assert.containsNone( + document.body, + '.o_EmojisPopover', + "emojis popover should be closed after pressing escape on emojis button" + ); + assert.containsOnce( + document.body, + '.o_Composer', + "reply composer should still be opened after pressing escape on emojis button" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "mention suggestion should be opened after typing @" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev); + }); + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "mention suggestion should be closed after pressing escape on mention suggestion" + ); + assert.containsOnce( + document.body, + '.o_Composer', + "reply composer should still be opened after pressing escape on mention suggestion" + ); + + await afterNextRender(() => { + const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev); + }); + assert.containsNone( + document.body, + '.o_Composer', + "reply composer should be closed after pressing escape if there was no other priority escape handler" + ); +}); + +QUnit.test('reply: discard on discard button click', async function (assert) { + assert.expect(4); + + this.data['mail.message'].records.push({ + body: "not empty", + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 20, + }); + await this.start(); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.containsOnce( + document.body, + '.o_Composer', + "should have composer after clicking on reply to message" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonDiscard', + "composer should have a discard button" + ); + + await afterNextRender(() => + document.querySelector(`.o_Composer_buttonDiscard`).click() + ); + assert.containsNone( + document.body, + '.o_Composer', + "reply composer should be closed after clicking on discard" + ); +}); + +QUnit.test('reply: discard on reply button toggle', async function (assert) { + assert.expect(3); + + this.data['mail.message'].records.push({ + body: "not empty", + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 20, + }); + await this.start(); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.containsOnce( + document.body, + '.o_Composer', + "should have composer after clicking on reply to message" + ); + + await afterNextRender(() => + document.querySelector(`.o_Message_commandReply`).click() + ); + assert.containsNone( + document.body, + '.o_Composer', + "reply composer should be closed after clicking on reply button again" + ); +}); + +QUnit.test('reply: discard on click away', async function (assert) { + assert.expect(7); + + this.data['mail.message'].records.push({ + body: "not empty", + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 20, + }); + await this.start(); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.containsOnce( + document.body, + '.o_Composer', + "should have composer after clicking on reply to message" + ); + + document.querySelector(`.o_ComposerTextInput_textarea`).click(); + await nextAnimationFrame(); // wait just in case, but nothing is supposed to happen + assert.containsOnce( + document.body, + '.o_Composer', + "reply composer should still be there after clicking inside itself" + ); + + await afterNextRender(() => + document.querySelector(`.o_Composer_buttonEmojis`).click() + ); + assert.containsOnce( + document.body, + '.o_EmojisPopover', + "emojis popover should be opened after clicking on emojis button" + ); + + await afterNextRender(() => { + document.querySelector(`.o_EmojisPopover_emoji`).click(); + }); + assert.containsNone( + document.body, + '.o_EmojisPopover', + "emojis popover should be closed after selecting an emoji" + ); + assert.containsOnce( + document.body, + '.o_Composer', + "reply composer should still be there after selecting an emoji (even though it is technically a click away, it should be considered inside)" + ); + + await afterNextRender(() => + document.querySelector(`.o_Message`).click() + ); + assert.containsNone( + document.body, + '.o_Composer', + "reply composer should be closed after clicking away" + ); +}); + +QUnit.test('"reply to" composer should log note if message replied to is a note', async function (assert) { + assert.expect(6); + + this.data['mail.message'].records.push({ + body: "not empty", + is_discussion: false, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 20, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + assert.strictEqual( + args.kwargs.message_type, + "comment", + "should set message type as 'comment'" + ); + assert.strictEqual( + args.kwargs.subtype_xmlid, + "mail.mt_note", + "should set subtype_xmlid as 'note'" + ); + } + return this._super(...arguments); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.strictEqual( + document.querySelector('.o_Composer_buttonSend').textContent.trim(), + "Log", + "Send button text should be 'Log'" + ); + + await afterNextRender(() => + document.execCommand('insertText', false, "Test") + ); + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.verifySteps(['message_post']); +}); + +QUnit.test('"reply to" composer should send message if message replied to is not a note', async function (assert) { + assert.expect(6); + + this.data['mail.message'].records.push({ + body: "not empty", + is_discussion: true, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 20, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + assert.strictEqual( + args.kwargs.message_type, + "comment", + "should set message type as 'comment'" + ); + assert.strictEqual( + args.kwargs.subtype_xmlid, + "mail.mt_comment", + "should set subtype_xmlid as 'comment'" + ); + } + return this._super(...arguments); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.strictEqual( + document.querySelector('.o_Composer_buttonSend').textContent.trim(), + "Send", + "Send button text should be 'Send'" + ); + + await afterNextRender(() => + document.execCommand('insertText', false, "Test") + ); + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.verifySteps(['message_post']); +}); + +QUnit.test('error notifications should not be shown in Inbox', async function (assert) { + assert.expect(3); + + this.data['mail.message'].records.push({ + body: "not empty", + id: 100, + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 20, + }); + this.data['mail.notification'].records.push({ + mail_message_id: 100, // id of related message + res_partner_id: this.data.currentPartnerId, // must be for current partner + notification_status: 'exception', + notification_type: 'email', + }); + await this.start(); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + assert.containsOnce( + document.body, + '.o_Message_originThreadLink', + "should display origin thread link" + ); + assert.containsNone( + document.body, + '.o_Message_notificationIcon', + "should not display any notification icon in Inbox" + ); +}); + +QUnit.test('show subject of message in Inbox', async function (assert) { + assert.expect(3); + + this.data['mail.message'].records.push({ + body: "not empty", + model: 'mail.channel', // random existing model + needaction: true, // message_fetch domain + needaction_partner_ids: [this.data.currentPartnerId], // not needed, for consistency + subject: "Salutations, voyageur", // will be asserted in the test + }); + await this.start(); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + assert.containsOnce( + document.body, + '.o_Message_subject', + "should display subject of the message" + ); + assert.strictEqual( + document.querySelector('.o_Message_subject').textContent, + "Subject: Salutations, voyageur", + "Subject of the message should be 'Salutations, voyageur'" + ); +}); + +QUnit.test('show subject of message in history', async function (assert) { + assert.expect(3); + + this.data['mail.message'].records.push({ + body: "not empty", + history_partner_ids: [3], // not needed, for consistency + model: 'mail.channel', // random existing model + subject: "Salutations, voyageur", // will be asserted in the test + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_history', + }, + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + assert.containsOnce( + document.body, + '.o_Message_subject', + "should display subject of the message" + ); + assert.strictEqual( + document.querySelector('.o_Message_subject').textContent, + "Subject: Salutations, voyageur", + "Subject of the message should be 'Salutations, voyageur'" + ); +}); + +QUnit.test('click on (non-channel/non-partner) origin thread link should redirect to form view', async function (assert) { + assert.expect(9); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + // Callback of doing an action (action manager). + // Expected to be called on click on origin thread link, + // which redirects to form view of record related to origin thread + assert.step('do-action'); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should open a view" + ); + assert.deepEqual( + payload.action.views, + [[false, 'form']], + "action should open form view" + ); + assert.strictEqual( + payload.action.res_model, + 'some.model', + "action should open view with model 'some.model' (model of message origin thread)" + ); + assert.strictEqual( + payload.action.res_id, + 10, + "action should open view with id 10 (id of message origin thread)" + ); + }); + this.data['some.model'] = { fields: {}, records: [{ id: 10 }] }; + this.data['mail.message'].records.push({ + body: "not empty", + model: 'some.model', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + record_name: "Some record", + res_id: 10, + }); + await this.start({ + env: { + bus, + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + assert.containsOnce( + document.body, + '.o_Message_originThreadLink', + "should display origin thread link" + ); + assert.strictEqual( + document.querySelector('.o_Message_originThreadLink').textContent, + "Some record", + "origin thread link should display record name" + ); + + document.querySelector('.o_Message_originThreadLink').click(); + assert.verifySteps(['do-action'], "should have made an action on click on origin thread (to open form view)"); +}); + +QUnit.test('subject should not be shown when subject is the same as the thread name', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + needaction: true, + subject: "Salutations, voyageur", + }); + this.data['mail.channel'].records.push({ + id: 100, + name: "Salutations, voyageur", + }); + await this.start(); + + assert.containsNone( + document.body, + '.o_Message_subject', + "subject should not be shown when subject is the same as the thread name" + ); +}); + +QUnit.test('subject should not be shown when subject is the same as the thread name and both have the same prefix', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + needaction: true, + subject: "Re: Salutations, voyageur", + }); + this.data['mail.channel'].records.push({ + id: 100, + name: "Re: Salutations, voyageur", + }); + await this.start(); + + assert.containsNone( + document.body, + '.o_Message_subject', + "subject should not be shown when subject is the same as the thread name and both have the same prefix" + ); +}); + +QUnit.test('subject should not be shown when subject differs from thread name only by the "Re:" prefix', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + needaction: true, + subject: "Re: Salutations, voyageur", + }); + this.data['mail.channel'].records.push({ + id: 100, + name: "Salutations, voyageur", + }); + await this.start(); + + assert.containsNone( + document.body, + '.o_Message_subject', + "should not display subject when subject differs from thread name only by the 'Re:' prefix" + ); +}); + +QUnit.test('subject should not be shown when subject differs from thread name only by the "Fw:" and "Re:" prefix', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + needaction: true, + subject: "Fw: Re: Salutations, voyageur", + }); + this.data['mail.channel'].records.push({ + id: 100, + name: "Salutations, voyageur", + }); + await this.start(); + + assert.containsNone( + document.body, + '.o_Message_subject', + "should not display subject when subject differs from thread name only by the 'Fw:' and Re:' prefix" + ); +}); + +QUnit.test('subject should be shown when the thread name has an extra prefix compared to subject', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + needaction: true, + subject: "Salutations, voyageur", + }); + this.data['mail.channel'].records.push({ + id: 100, + name: "Re: Salutations, voyageur", + }); + await this.start(); + + assert.containsOnce( + document.body, + '.o_Message_subject', + "subject should be shown when the thread name has an extra prefix compared to subject" + ); +}); + +QUnit.test('subject should not be shown when subject differs from thread name only by the "fw:" prefix and both contain another common prefix', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + needaction: true, + subject: "fw: re: Salutations, voyageur", + }); + this.data['mail.channel'].records.push({ + id: 100, + name: "Re: Salutations, voyageur", + }); + await this.start(); + + assert.containsNone( + document.body, + '.o_Message_subject', + "subject should not be shown when subject differs from thread name only by the 'fw:' prefix and both contain another common prefix" + ); +}); + +QUnit.test('subject should not be shown when subject differs from thread name only by the "Re: Re:" prefix', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + needaction: true, + subject: "Re: Re: Salutations, voyageur", + }); + this.data['mail.channel'].records.push({ + id: 100, + name: "Salutations, voyageur", + }); + await this.start(); + + assert.containsNone( + document.body, + '.o_Message_subject', + "should not display subject when subject differs from thread name only by the 'Re: Re:'' prefix" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js new file mode 100644 index 00000000..c4c53cb5 --- /dev/null +++ b/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js @@ -0,0 +1,1180 @@ +odoo.define('mail/static/src/components/discuss/tests/discuss_moderation_tests.js', function (require) { +'use strict'; + +const { + afterEach, + afterNextRender, + beforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_moderation_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('as moderator, moderated channel with pending moderation message', async function (assert) { + assert.expect(37); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + is_moderator: true, // current user is expected to be moderator of channel + moderation: true, // for consistency, but not used in the scope of this test + name: "general", // random name, will be asserted in the test + }); + this.data['mail.message'].records.push({ + body: "<p>test</p>", // random body, will be asserted in the test + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending moderation + res_id: 20, // id of the channel + }); + await this.start(); + + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `), + "should display the moderation box in the sidebar" + ); + const mailboxCounter = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + .o_DiscussSidebarItem_counter + `); + assert.ok( + mailboxCounter, + "there should be a counter next to the moderation mailbox in the sidebar" + ); + assert.strictEqual( + mailboxCounter.textContent.trim(), + "1", + "the mailbox counter of the moderation mailbox should display '1'" + ); + + // 1. go to moderation mailbox + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `).click() + ); + // check message + assert.containsOnce( + document.body, + '.o_Message', + "should be only one message in moderation box" + ); + assert.strictEqual( + document.querySelector('.o_Message_content').textContent, + "test", + "this message pending moderation should have the correct content" + ); + assert.containsOnce( + document.body, + '.o_Message_originThreadLink', + "thee message should have one origin" + ); + assert.strictEqual( + document.querySelector('.o_Message_originThreadLink').textContent, + "#general", + "the message pending moderation should have correct origin as its linked document" + ); + assert.containsOnce( + document.body, + '.o_Message_checkbox', + "there should be a moderation checkbox next to the message" + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should be unchecked by default" + ); + // check select all (enabled) / unselect all (disabled) buttons + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonSelectAll', + "there should be a 'Select All' button in the control panel" + ); + assert.doesNotHaveClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'), + 'disabled', + "the 'Select All' button should not be disabled" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonUnselectAll', + "there should be a 'Unselect All' button in the control panel" + ); + assert.hasClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'), + 'disabled', + "the 'Unselect All' button should be disabled" + ); + // check moderate all buttons (invisible) + assert.containsN( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration', + 3, + "there should be 3 buttons to moderate selected messages in the control panel" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-accept', + "there should one moderate button to accept messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + "the moderate button 'Accept' should be invisible by default" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-reject', + "there should one moderate button to reject messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'), + "the moderate button 'Reject' should be invisible by default" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-discard', + "there should one moderate button to discard messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'), + "the moderate button 'Discard' should be invisible by default" + ); + + // click on message moderation checkbox + await afterNextRender(() => document.querySelector('.o_Message_checkbox').click()); + assert.ok( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become checked after click" + ); + // check select all (disabled) / unselect all buttons (enabled) + assert.hasClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'), + 'disabled', + "the 'Select All' button should be disabled" + ); + assert.doesNotHaveClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'), + 'disabled', + "the 'Unselect All' button should not be disabled" + ); + // check moderate all buttons updated (visible) + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + "the moderate button 'Accept' should be visible" + ); + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'), + "the moderate button 'Reject' should be visible" + ); + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'), + "the moderate button 'Discard' should be visible" + ); + + // test select buttons + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click() + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become unchecked after click" + ); + + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click() + ); + assert.ok( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become checked again after click" + ); + + // 2. go to channel 'general' + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).click() + ); + // check correct message + assert.containsOnce( + document.body, + '.o_Message', + "should be only one message in general channel" + ); + assert.containsOnce( + document.body, + '.o_Message_checkbox', + "there should be a moderation checkbox next to the message" + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should not be checked here" + ); + await afterNextRender(() => document.querySelector('.o_Message_checkbox').click()); + // Don't test moderation actions visibility, since it is similar to moderation box. + + // 3. test discard button + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard').click() + ); + assert.containsOnce( + document.body, + '.o_ModerationDiscardDialog', + "discard dialog should be open" + ); + // the dialog will be tested separately + await afterNextRender(() => + document.querySelector('.o_ModerationDiscardDialog .o-cancel').click() + ); + assert.containsNone( + document.body, + '.o_ModerationDiscardDialog', + "discard dialog should be closed" + ); + + // 4. test reject button + await afterNextRender(() => + document.querySelector(` + .o_widget_Discuss_controlPanelButtonModeration.o-reject + `).click() + ); + assert.containsOnce( + document.body, + '.o_ModerationRejectDialog', + "reject dialog should be open" + ); + // the dialog will be tested separately + await afterNextRender(() => + document.querySelector('.o_ModerationRejectDialog .o-cancel').click() + ); + assert.containsNone( + document.body, + '.o_ModerationRejectDialog', + "reject dialog should be closed" + ); + + // 5. test accept button + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click() + ); + assert.containsOnce( + document.body, + '.o_Message', + "should still be only one message in general channel" + ); + assert.containsNone( + document.body, + '.o_Message_checkbox', + "there should not be a moderation checkbox next to the message" + ); +}); + +QUnit.test('as moderator, accept pending moderation message', async function (assert) { + assert.expect(12); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + is_moderator: true, // current user is expected to be moderator of channel + moderation: true, // for consistency, but not used in the scope of this test + name: "general", // random name, will be asserted in the test + }); + this.data['mail.message'].records.push({ + body: "<p>test</p>", // random body, will be asserted in the test + id: 100, // random unique id, will be asserted during the test + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending moderation + res_id: 20, // id of the channel + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + assert.strictEqual( + messageIDs.length, + 1, + "should moderate one message" + ); + assert.strictEqual( + messageIDs[0], + 100, + "should moderate message with ID 100" + ); + assert.strictEqual( + decision, + 'accept', + "should accept the message" + ); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + assert.ok( + document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `), + "should display the message to moderate" + ); + const acceptButton = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + .o_Message_moderationAction.o-accept + `); + assert.ok(acceptButton, "should display the accept button"); + + await afterNextRender(() => acceptButton.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const message = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `); + assert.ok( + message, + "should display the accepted message" + ); + assert.containsNone( + message, + '.o_Message_moderationPending', + "the message should not be pending moderation" + ); +}); + +QUnit.test('as moderator, reject pending moderation message (reject with explanation)', async function (assert) { + assert.expect(23); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + is_moderator: true, // current user is expected to be moderator of channel + moderation: true, // for consistency, but not used in the scope of this test + name: "general", // random name, will be asserted in the test + }); + this.data['mail.message'].records.push({ + body: "<p>test</p>", // random body, will be asserted in the test + id: 100, // random unique id, will be asserted during the test + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending moderation + res_id: 20, // id of the channel + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + const kwargs = args.kwargs; + assert.strictEqual( + messageIDs.length, + 1, + "should moderate one message" + ); + assert.strictEqual( + messageIDs[0], + 100, + "should moderate message with ID 100" + ); + assert.strictEqual( + decision, + 'reject', + "should reject the message" + ); + assert.strictEqual( + kwargs.title, + "Message Rejected", + "should have correct reject message title" + ); + assert.strictEqual( + kwargs.comment, + "Your message was rejected by moderator.", + "should have correct reject message body / comment" + ); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + const pendingMessage = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `); + assert.ok( + pendingMessage, + "should display the message to moderate" + ); + const rejectButton = pendingMessage.querySelector(':scope .o_Message_moderationAction.o-reject'); + assert.ok( + rejectButton, + "should display the reject button" + ); + + await afterNextRender(() => rejectButton.click()); + const dialog = document.querySelector('.o_ModerationRejectDialog'); + assert.ok( + dialog, + "a dialog should be prompt to the moderator on click reject" + ); + assert.strictEqual( + dialog.querySelector('.modal-title').textContent, + "Send explanation to author", + "dialog should have correct title" + ); + + const messageTitle = dialog.querySelector(':scope .o_ModerationRejectDialog_title'); + assert.ok( + messageTitle, + "should have a title for rejecting" + ); + assert.hasAttrValue( + messageTitle, + 'placeholder', + "Subject", + "title for reject reason should have correct placeholder" + ); + assert.strictEqual( + messageTitle.value, + "Message Rejected", + "title for reject reason should have correct default value" + ); + + const messageComment = dialog.querySelector(':scope .o_ModerationRejectDialog_comment'); + assert.ok( + messageComment, + "should have a comment for rejecting" + ); + assert.hasAttrValue( + messageComment, + 'placeholder', + "Mail Body", + "comment for reject reason should have correct placeholder" + ); + assert.strictEqual( + messageComment.value, + "Your message was rejected by moderator.", + "comment for reject reason should have correct default text content" + ); + const confirmReject = dialog.querySelector(':scope .o-reject'); + assert.ok( + confirmReject, + "should have reject button" + ); + assert.strictEqual( + confirmReject.textContent, + "Reject" + ); + + await afterNextRender(() => confirmReject.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `); + assert.ok( + channel, + 'should display the general channel' + ); + + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should now have no message in channel" + ); +}); + +QUnit.test('as moderator, discard pending moderation message (reject without explanation)', async function (assert) { + assert.expect(16); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + is_moderator: true, // current user is expected to be moderator of channel + moderation: true, // for consistency, but not used in the scope of this test + name: "general", // random name, will be asserted in the test + }); + this.data['mail.message'].records.push({ + body: "<p>test</p>", // random body, will be asserted in the test + id: 100, // random unique id, will be asserted during the test + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending moderation + res_id: 20, // id of the channel + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + assert.strictEqual(messageIDs.length, 1, "should moderate one message"); + assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100"); + assert.strictEqual(decision, 'discard', "should discard the message"); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + const pendingMessage = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `); + assert.ok( + pendingMessage, + "should display the message to moderate" + ); + + const discardButton = pendingMessage.querySelector(` + :scope .o_Message_moderationAction.o-discard + `); + assert.ok( + discardButton, + "should display the discard button" + ); + + await afterNextRender(() => discardButton.click()); + const dialog = document.querySelector('.o_ModerationDiscardDialog'); + assert.ok( + dialog, + "a dialog should be prompt to the moderator on click discard" + ); + assert.strictEqual( + dialog.querySelector('.modal-title').textContent, + "Confirmation", + "dialog should have correct title" + ); + assert.strictEqual( + dialog.textContent, + "Confirmation×You are going to discard 1 message.Do you confirm the action?DiscardCancel", + "should warn the user on discard action" + ); + + const confirmDiscard = dialog.querySelector(':scope .o-discard'); + assert.ok( + confirmDiscard, + "should have discard button" + ); + assert.strictEqual( + confirmDiscard.textContent, + "Discard" + ); + + await afterNextRender(() => confirmDiscard.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should now have no message in channel" + ); +}); + +QUnit.test('as author, send message in moderated channel', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + moderation: true, // channel must be moderated to test the feature + name: "general", // random name, will be asserted in the test + }); + await this.start(); + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + // go to channel 'general' + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should have no message in channel" + ); + + // post a message + await afterNextRender(() => { + const textInput = document.querySelector('.o_ComposerTextInput_textarea'); + textInput.focus(); + document.execCommand('insertText', false, "Some Text"); + }); + await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click()); + const messagePending = document.querySelector('.o_Message_moderationPending'); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); +}); + +QUnit.test('as author, sent message accepted in moderated channel', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + moderation: true, // for consistency, but not used in the scope of this test + name: "general", // random name, will be asserted in the test + }); + this.data['mail.message'].records.push({ + body: "not empty", + id: 100, // random unique id, will be referenced in the test + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending + res_id: 20, // id of the channel + }); + await this.start(); + + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const messagePending = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + .o_Message_moderationPending + `); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); + + // simulate accepted message + await afterNextRender(() => { + const messageData = { + id: 100, + moderation_status: 'accepted', + }; + const notification = [[false, 'mail.channel', 20], messageData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + + // check message is accepted + const message = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `); + assert.ok( + message, + "should still display the message" + ); + assert.containsNone( + message, + '.o_Message_moderationPending', + "the message should not be in pending moderation anymore" + ); +}); + +QUnit.test('as author, sent message rejected in moderated channel', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + moderation: true, // for consistency, but not used in the scope of this test + name: "general", // random name, will be asserted in the test + }); + this.data['mail.message'].records.push({ + body: "not empty", + id: 100, // random unique id, will be referenced in the test + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending + res_id: 20, // id of the channel + }); + await this.start(); + + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const messagePending = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + .o_Message_moderationPending + `); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); + + // simulate reject from moderator + await afterNextRender(() => { + const notifData = { + type: 'deletion', + message_ids: [100], + }; + const notification = [[false, 'res.partner', this.env.messaging.currentPartner.id], notifData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + // check no message + assert.containsNone( + document.body, + '.o_Message', + "message should be removed from channel after reject" + ); +}); + +QUnit.test('as moderator, pending moderation message accessibility', async function (assert) { + // pending moderation message should appear in moderation box and in origin thread + assert.expect(3); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + is_moderator: true, // current user is expected to be moderator of channel + moderation: true, // channel must be moderated to test the feature + }); + this.data['mail.message'].records.push({ + body: "not empty", + id: 100, // random unique id, will be referenced in the test + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending + res_id: 20, // id of the channel + }); + await this.start(); + + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ id: 20, model: 'mail.channel' }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `), + "should display the moderation box in the sidebar" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${thread.localId}"] + `).click() + ); + const message = this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }); + assert.containsOnce( + document.body, + `.o_Message[data-message-local-id="${message.localId}"]`, + "the pending moderation message should be in the channel" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `).click() + ); + assert.containsOnce( + document.body, + `.o_Message[data-message-local-id="${message.localId}"]`, + "the pending moderation message should be in moderation box" + ); +}); + +QUnit.test('as author, pending moderation message should appear in origin thread', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + moderation: true, // channel must be moderated to test the feature + }); + this.data['mail.message'].records.push({ + author_id: this.data.currentPartnerId, // test as author of message + body: "not empty", + id: 100, // random unique id, will be referenced in the test + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending + res_id: 20, // id of the channel + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ id: 20, model: 'mail.channel' }); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${thread.localId}"] + `).click() + ); + const message = this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }); + assert.containsOnce( + document.body, + `.o_Message[data-message-local-id="${message.localId}"]`, + "the pending moderation message should be in the channel" + ); +}); + +QUnit.test('as moderator, new pending moderation message posted by someone else', async function (assert) { + // the message should appear in origin thread and moderation box if I moderate it + assert.expect(3); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + is_moderator: true, // current user is expected to be moderator of channel + moderation: true, // channel must be moderated to test the feature + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ id: 20, model: 'mail.channel' }); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${thread.localId}"] + `).click() + ); + assert.containsNone( + document.body, + `.o_Message`, + "should have no message in the channel initially" + ); + + // simulate receiving the message + const messageData = { + author_id: [10, 'john doe'], // random id, different than current partner + body: "not empty", + channel_ids: [], // server do NOT return channel_id of the message if pending moderation + id: 1, // random unique id + model: 'mail.channel', // expected value to link message to channel + moderation_status: 'pending_moderation', // message is expected to be pending + res_id: 20, // id of the channel + }; + await afterNextRender(() => { + const notifications = [[ + ['my-db', 'res.partner', this.env.messaging.currentPartner.id], + { type: 'moderator', message: messageData }, + ]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + const message = this.env.models['mail.message'].findFromIdentifyingData({ id: 1 }); + assert.containsOnce( + document.body, + `.o_Message[data-message-local-id="${message.localId}"]`, + "the pending moderation message should be in the channel" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `).click() + ); + assert.containsOnce( + document.body, + `.o_Message[data-message-local-id="${message.localId}"]`, + "the pending moderation message should be in moderation box" + ); +}); + +QUnit.test('accept multiple moderation messages', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + is_moderator: true, // current user is expected to be moderator of channel + moderation: true, // channel must be moderated to test the feature + }); + this.data['mail.message'].records.push( + { + body: "not empty", + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + }, + { + body: "not empty", + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + }, + { + body: "not empty", + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + } + ); + + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_moderation', + }, + }, + }); + + assert.containsN( + document.body, + '.o_Message', + 3, + "should initially display 3 messages" + ); + + await afterNextRender(() => { + document.querySelectorAll('.o_Message_checkbox')[0].click(); + document.querySelectorAll('.o_Message_checkbox')[1].click(); + }); + assert.containsN( + document.body, + '.o_Message_checkbox:checked', + 2, + "2 messages should have been checked after clicking on their respective checkbox" + ); + assert.doesNotHaveClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + 'o_hidden', + "global accept button should be displayed as two messages are selected" + ); + + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click() + ); + assert.containsN( + document.body, + '.o_Message', + 1, + "should display 1 message as the 2 others have been accepted" + ); + assert.hasClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + 'o_hidden', + "global accept button should no longer be displayed as messages have been unselected" + ); +}); + +QUnit.test('accept multiple moderation messages after having accepted other messages', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message and will be referenced in the test + is_moderator: true, // current user is expected to be moderator of channel + moderation: true, // channel must be moderated to test the feature + }); + this.data['mail.message'].records.push( + { + body: "not empty", + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + }, + { + body: "not empty", + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + }, + { + body: "not empty", + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_moderation', + }, + }, + }); + assert.containsN( + document.body, + '.o_Message', + 3, + "should initially display 3 messages" + ); + + await afterNextRender(() => { + document.querySelectorAll('.o_Message_checkbox')[0].click(); + }); + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click() + ); + await afterNextRender(() => document.querySelectorAll('.o_Message_checkbox')[0].click()); + assert.containsOnce( + document.body, + '.o_Message_checkbox:checked', + "a message should have been checked after clicking on its checkbox" + ); + assert.doesNotHaveClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + 'o_hidden', + "global accept button should be displayed as a message is selected" + ); + + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click() + ); + assert.containsOnce( + document.body, + '.o_Message', + "should display only one message left after the two others has been accepted" + ); + assert.hasClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + 'o_hidden', + "global accept button should no longer be displayed as message has been unselected" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js new file mode 100644 index 00000000..a24ce411 --- /dev/null +++ b/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js @@ -0,0 +1,238 @@ +odoo.define('mail/static/src/components/discuss/tests/discuss_pinned_tests.js', function (require) { +'use strict'; + +const { + afterEach, + afterNextRender, + beforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_pinned_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('sidebar: pinned channel 1: init with one pinned channel', async function (assert) { + assert.expect(2); + + // channel that is expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${this.env.messaging.inbox.localId}"]`, + "The Inbox is opened in discuss" + ); + assert.containsOnce( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"]`, + "should have the only channel of which user is member in discuss sidebar" + ); +}); + +QUnit.test('sidebar: pinned channel 2: open pinned channel', async function (assert) { + assert.expect(1); + + // channel that is expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + + const threadGeneral = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${ + threadGeneral.localId + }"]`).click() + ); + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel #General is displayed in discuss" + ); +}); + +QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async function (assert) { + assert.expect(8); + + // channel that is expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ + id: 20, + is_minimized: true, + state: 'open', + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'execute_command') { + assert.step('execute_command'); + assert.deepEqual(args.args[0], [20], + "The right id is sent to the server to remove" + ); + assert.strictEqual(args.kwargs.command, 'leave', + "The right command is sent to the server" + ); + } + if (args.method === 'channel_fold') { + assert.step('channel_fold'); + } + return this._super(...arguments); + }, + }); + + const threadGeneral = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${ + threadGeneral.localId + }"]`).click() + ); + assert.verifySteps([], "neither channel_fold nor execute_command are called yet"); + await afterNextRender(() => + document.querySelector('.o_DiscussSidebarItem_commandLeave').click() + ); + assert.verifySteps( + [ + 'channel_fold', + 'execute_command' + ], + "both channel_fold and execute_command have been called when unpinning a channel" + ); + assert.containsNone( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel must have been removed from discuss sidebar" + ); + assert.containsOnce( + document.body, + '.o_Discuss_noThread', + "should have no thread opened in discuss" + ); +}); + +QUnit.test('sidebar: unpin channel from bus', async function (assert) { + assert.expect(5); + + // channel that is expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const threadGeneral = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${this.env.messaging.inbox.localId}"]`, + "The Inbox is opened in discuss" + ); + assert.containsOnce( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`, + "1 channel is present in discuss sidebar and it is 'general'" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${ + threadGeneral.localId + }"]`).click() + ); + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel #General is opened in discuss" + ); + + // Simulate receiving a leave channel notification + // (e.g. from user interaction from another device or browser tab) + await afterNextRender(() => { + const notif = [ + ["dbName", 'res.partner', this.env.messaging.currentPartner.id], + { + channel_type: 'channel', + id: 20, + info: 'unsubscribe', + name: "General", + public: 'public', + state: 'open', + } + ]; + this.env.services.bus_service.trigger('notification', [notif]); + }); + assert.containsOnce( + document.body, + '.o_Discuss_noThread', + "should have no thread opened in discuss" + ); + assert.containsNone( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel must have been removed from discuss sidebar" + ); +}); + +QUnit.test('[technical] sidebar: channel group_based_subscription: mandatorily pinned', async function (assert) { + assert.expect(2); + + // FIXME: The following is admittedly odd. + // Fixing it should entail a deeper reflexion on the group_based_subscription + // and is_pinned functionalities, especially in python. + // task-2284357 + + // channel that is expected to be found in the sidebar + this.data['mail.channel'].records.push({ + group_based_subscription: true, // expected value for this test + id: 20, // random unique id, will be referenced in the test + is_pinned: false, // expected value for this test + }); + await this.start(); + const threadGeneral = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + assert.containsOnce( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel #General is in discuss sidebar" + ); + assert.containsNone( + document.body, + 'o_DiscussSidebarItem_commandLeave', + "The group_based_subscription channel is not unpinnable" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss/tests/discuss_sidebar_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_sidebar_tests.js new file mode 100644 index 00000000..34c884eb --- /dev/null +++ b/addons/mail/static/src/components/discuss/tests/discuss_sidebar_tests.js @@ -0,0 +1,163 @@ +odoo.define('mail/static/src/components/discuss/tests/discuss_sidebar_tests.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { + afterEach, + afterNextRender, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_sidebar_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('sidebar find shows channels matching search term', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ + channel_partner_ids: [], + channel_type: 'channel', + id: 20, + members: [], + name: 'test', + public: 'public', + }); + const searchReadDef = makeDeferred(); + await this.start({ + async mockRPC(route, args) { + const res = await this._super(...arguments); + if (args.method === 'search_read') { + searchReadDef.resolve(); + } + return res; + }, + }); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_groupHeaderItemAdd`).click() + ); + document.querySelector(`.o_DiscussSidebar_itemNew`).focus(); + document.execCommand('insertText', false, "test"); + document.querySelector(`.o_DiscussSidebar_itemNew`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_DiscussSidebar_itemNew`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + + await searchReadDef; + await nextAnimationFrame(); // ensures search_read rpc is rendered. + const results = document.querySelectorAll('.ui-autocomplete .ui-menu-item a'); + assert.ok( + results, + "should have autocomplete suggestion after typing on 'find or create channel' input" + ); + assert.strictEqual( + results.length, + // When searching for a single existing channel, the results list will have at least 3 lines: + // One for the existing channel itself + // One for creating a public channel with the search term + // One for creating a private channel with the search term + 3 + ); + assert.strictEqual( + results[0].textContent, + "test", + "autocomplete suggestion should target the channel matching search term" + ); +}); + +QUnit.test('sidebar find shows channels matching search term even when user is member', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ + channel_partner_ids: [this.data.currentPartnerId], + channel_type: 'channel', + id: 20, + members: [this.data.currentPartnerId], + name: 'test', + public: 'public', + }); + const searchReadDef = makeDeferred(); + await this.start({ + async mockRPC(route, args) { + const res = await this._super(...arguments); + if (args.method === 'search_read') { + searchReadDef.resolve(); + } + return res; + }, + }); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_groupHeaderItemAdd`).click() + ); + document.querySelector(`.o_DiscussSidebar_itemNew`).focus(); + document.execCommand('insertText', false, "test"); + document.querySelector(`.o_DiscussSidebar_itemNew`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_DiscussSidebar_itemNew`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + + await searchReadDef; + await nextAnimationFrame(); + const results = document.querySelectorAll('.ui-autocomplete .ui-menu-item a'); + assert.ok( + results, + "should have autocomplete suggestion after typing on 'find or create channel' input" + ); + assert.strictEqual( + results.length, + // When searching for a single existing channel, the results list will have at least 3 lines: + // One for the existing channel itself + // One for creating a public channel with the search term + // One for creating a private channel with the search term + 3 + ); + assert.strictEqual( + results[0].textContent, + "test", + "autocomplete suggestion should target the channel matching search term even if user is member" + ); +}); + +QUnit.test('sidebar channels should be ordered case insensitive alphabetically', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push( + { id: 19, name: "Xyz" }, + { id: 20, name: "abc" }, + { id: 21, name: "Abc" }, + { id: 22, name: "Xyz" } + ); + await this.start(); + const results = document.querySelectorAll('.o_DiscussSidebar_groupChannel .o_DiscussSidebarItem_name'); + assert.deepEqual( + [results[0].textContent, results[1].textContent, results[2].textContent, results[3].textContent], + ["abc", "Abc", "Xyz", "Xyz"], + "Channel name should be in case insensitive alphabetical order" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js new file mode 100644 index 00000000..dc2005e5 --- /dev/null +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -0,0 +1,4447 @@ +odoo.define('mail/static/src/components/discuss/tests/discuss_tests.js', function (require) { +'use strict'; + +const BusService = require('bus.BusService'); + +const { + afterEach, + afterNextRender, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); +const { makeTestPromise, file: { createFile, inputFiles } } = require('web.test_utils'); + +const { + applyFilter, + toggleAddCustomFilter, + toggleFilterMenu, +} = require('web.test_utils_control_panel'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { afterEvent, env, widget } = await start(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.afterEvent = afterEvent; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('messaging not initialized', async function (assert) { + assert.expect(1); + + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await makeTestPromise(); // simulate messaging never initialized + } + return _super(); + }, + waitUntilMessagingCondition: 'created', + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 1, + "should display messaging not initialized" + ); +}); + +QUnit.test('messaging becomes initialized', async function (assert) { + assert.expect(2); + + const messagingInitializedProm = makeTestPromise(); + + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await messagingInitializedProm; + } + return _super(); + }, + waitUntilMessagingCondition: 'created', + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 1, + "should display messaging not initialized" + ); + + await afterNextRender(() => messagingInitializedProm.resolve()); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 0, + "should no longer display messaging not initialized" + ); +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(4); + + await this.start(); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_sidebar').length, + 1, + "should have a sidebar section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_content').length, + 1, + "should have content section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_thread').length, + 1, + "should have thread section inside content" + ); + assert.ok( + document.querySelector('.o_Discuss_thread').classList.contains('o_ThreadView'), + "thread section should use ThreadView component" + ); +}); + +QUnit.test('basic rendering: sidebar', async function (assert) { + assert.expect(20); + + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_group`).length, + 3, + "should have 3 groups in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupMailbox`).length, + 1, + "should have group 'Mailbox' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_groupHeader + `).length, + 0, + "mailbox category should not have any header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_item + `).length, + 3, + "should have 3 mailbox items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).length, + 1, + "should have inbox mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).length, + 1, + "should have starred mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).length, + 1, + "should have history mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_sidebar .o_DiscussSidebar_separator`).length, + 1, + "should have separator (between mailboxes and channels, but that's not tested)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel`).length, + 1, + "should have group 'Channel' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeader + `).length, + 1, + "channel category should have a header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle + `).length, + 1, + "should have title in channel header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle + `).textContent.trim(), + "Channels" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_list`).length, + 1, + "channel category should list items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 0, + "channel category should have no item by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat`).length, + 1, + "should have group 'Chat' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeader`).length, + 1, + "channel category should have a header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle`).length, + 1, + "should have title in chat header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle + `).textContent.trim(), + "Direct Messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_list`).length, + 1, + "chat category should list items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 0, + "chat category should have no item by default" + ); +}); + +QUnit.test('sidebar: basic mailbox rendering', async function (assert) { + assert.expect(6); + + await this.start(); + const inbox = document.querySelector(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "mailbox should have active indicator" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "mailbox should have an icon" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_ThreadIcon_mailboxInbox`).length, + 1, + "inbox should have 'inbox' icon" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "mailbox should have a name" + ); + assert.strictEqual( + inbox.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Inbox", + "inbox should have name 'Inbox'" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "should have no counter when equal to 0 (default value)" + ); +}); + +QUnit.test('sidebar: default active inbox', async function (assert) { + assert.expect(1); + + await this.start(); + const inbox = document.querySelector(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `); + assert.ok( + inbox.querySelector(` + :scope .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox should be active by default" + ); +}); + +QUnit.test('sidebar: change item', async function (assert) { + assert.expect(4); + + await this.start(); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox should be active by default" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred should be inactive by default" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox mailbox should become inactive" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active"); +}); + +QUnit.test('sidebar: inbox with counter', async function (assert) { + assert.expect(2); + + // notification expected to be counted at init_messaging + this.data['mail.notification'].records.push({ res_partner_id: this.data.currentPartnerId }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 1, + "should display a counter (= have a counter when different from 0)" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "1", + "should have counter value" + ); +}); + +QUnit.test('sidebar: add channel', async function (assert) { + assert.expect(3); + + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_groupHeaderItemAdd + `).length, + 1, + "should be able to add channel from header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_groupHeaderItemAdd + `).title, + "Add or join a channel"); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_itemNew`).length, + 1, + "should have item to add a new channel" + ); +}); + +QUnit.test('sidebar: basic channel rendering', async function (assert) { + assert.expect(14); + + // channel expected to be found in the sidebar, + // with a random unique id and name that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, name: "General" }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 1, + "should have one channel item"); + let channel = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item + `); + assert.strictEqual( + channel.dataset.threadLocalId, + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId, + "should have channel with Id 20" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "should have active indicator" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length, + 0, + "should not be active by default" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have an icon" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "should have a name" + ); + assert.strictEqual( + channel.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "General", + "should have name value" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length, + 1, + "should have commands" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 2, + "should have 2 commands" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length, + 1, + "should have 'settings' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length, + 1, + "should have 'leave' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 0, + "should have a counter when equals 0 (default value)" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).click() + ); + channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length, + 1, + "channel should become active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_ThreadView_composer`).length, + 1, + "should have composer section inside thread content (can post message in channel)" + ); +}); + +QUnit.test('sidebar: channel rendering with needaction counter', async function (assert) { + assert.expect(5); + + // channel expected to be found in the sidebar + // with a random unique id that will be used to link message + this.data['mail.channel'].records.push({ id: 20 }); + // expected needaction message + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], // link message to channel + id: 100, // random unique id, useful to link notification + }); + // expected needaction notification + this.data['mail.notification'].records.push({ + mail_message_id: 100, // id of related message + res_partner_id: this.data.currentPartnerId, // must be for current partner + }); + await this.start(); + const channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 1, + "should have a counter when different from 0" + ); + assert.strictEqual( + channel.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent, + "1", + "should have counter value" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 1, + "should have single command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length, + 1, + "should have 'settings' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length, + 0, + "should not have 'leave' command" + ); +}); + +QUnit.test('sidebar: mailing channel', async function (assert) { + assert.expect(1); + + // channel that is expected to be in the sidebar, with proper mass_mailing value + this.data['mail.channel'].records.push({ mass_mailing: true }); + await this.start(); + assert.containsOnce( + document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`), + '.fa.fa-envelope-o', + "should have an icon to indicate that the channel is a mailing channel" + ); +}); + +QUnit.test('sidebar: public/private channel rendering', async function (assert) { + assert.expect(5); + + // channels that are expected to be found in the sidebar (one public, one private) + // with random unique id and name that will be referenced in the test + this.data['mail.channel'].records.push( + { id: 100, name: "channel1", public: 'public', }, + { id: 101, name: "channel2", public: 'private' } + ); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 2, + "should have 2 channel items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have channel1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 101, + model: 'mail.channel' + }).localId + }"] + `).length, + 1, + "should have channel2 (Id 101)" + ); + const channel1 = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel' + }).localId + }"] + `); + const channel2 = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 101, + model: 'mail.channel' + }).localId + }"] + `); + assert.strictEqual( + channel1.querySelectorAll(`:scope .o_ThreadIcon_channelPublic`).length, + 1, + "channel1 (public) has hashtag icon" + ); + assert.strictEqual( + channel2.querySelectorAll(`:scope .o_ThreadIcon_channelPrivate`).length, + 1, + "channel2 (private) has lock icon" + ); +}); + +QUnit.test('sidebar: basic chat rendering', async function (assert) { + assert.expect(11); + + // expected correspondent, with a random unique id that will be used to link + // partner to chat and a random name that will be asserted in the test + this.data['res.partner'].records.push({ id: 17, name: "Demo" }); + // chat expected to be found in the sidebar + this.data['mail.channel'].records.push({ + channel_type: 'chat', // testing a chat is the goal of the test + id: 10, // random unique id, will be referenced in the test + members: [this.data.currentPartnerId, 17], // expected partners + public: 'private', // expected value for testing a chat + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 1, + "should have one chat item" + ); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.dataset.threadLocalId, + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel' + }).localId, + "should have chat with Id 10" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "should have active indicator" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have an icon" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "should have a name" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Demo", + "should have correspondent name as name" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length, + 1, + "should have commands" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 2, + "should have 2 commands" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length, + 1, + "should have 'rename' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length, + 1, + "should have 'unpin' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 0, + "should have a counter when equals 0 (default value)" + ); +}); + +QUnit.test('sidebar: chat rendering with unread counter', async function (assert) { + assert.expect(5); + + // chat expected to be found in the sidebar + this.data['mail.channel'].records.push({ + channel_type: 'chat', // testing a chat is the goal of the test + id: 10, // random unique id, will be referenced in the test + message_unread_counter: 100, + public: 'private', // expected value for testing a chat + }); + await this.start(); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 1, + "should have a counter when different from 0" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent, + "100", + "should have counter value" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 1, + "should have single command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length, + 1, + "should have 'rename' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length, + 0, + "should not have 'unpin' command" + ); +}); + +QUnit.test('sidebar: chat im_status rendering', async function (assert) { + assert.expect(7); + + // expected correspondent, with a random unique id that will be used to link + // partner to chat, and various im_status values to assert + this.data['res.partner'].records.push( + { id: 101, im_status: 'offline', name: "Partner1" }, + { id: 102, im_status: 'online', name: "Partner2" }, + { id: 103, im_status: 'away', name: "Partner3" } + ); + // chats expected to be found in the sidebar + this.data['mail.channel'].records.push( + { + channel_type: 'chat', // testing a chat is the goal of the test + id: 11, // random unique id, will be referenced in the test + members: [this.data.currentPartnerId, 101], // expected partners + public: 'private', // expected value for testing a chat + }, + { + channel_type: 'chat', // testing a chat is the goal of the test + id: 12, // random unique id, will be referenced in the test + members: [this.data.currentPartnerId, 102], // expected partners + public: 'private', // expected value for testing a chat + }, + { + channel_type: 'chat', // testing a chat is the goal of the test + id: 13, // random unique id, will be referenced in the test + members: [this.data.currentPartnerId, 103], // expected partners + public: 'private', // expected value for testing a chat + } + ); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 3, + "should have 3 chat items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have Partner1 (Id 11)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have Partner2 (Id 12)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 13, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have Partner3 (Id 13)" + ); + const chat1 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }).localId + }"] + `); + const chat2 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }).localId + }"] + `); + const chat3 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 13, + model: 'mail.channel', + }).localId + }"] + `); + assert.strictEqual( + chat1.querySelectorAll(`:scope .o_ThreadIcon_offline`).length, + 1, + "chat1 should have offline icon" + ); + assert.strictEqual( + chat2.querySelectorAll(`:scope .o_ThreadIcon_online`).length, + 1, + "chat2 should have online icon" + ); + assert.strictEqual( + chat3.querySelectorAll(`:scope .o_ThreadIcon_away`).length, + 1, + "chat3 should have away icon" + ); +}); + +QUnit.test('sidebar: chat custom name', async function (assert) { + assert.expect(1); + + // expected correspondent, with a random unique id that will be used to link + // partner to chat, and a random name not used in the scope of this test but set for consistency + this.data['res.partner'].records.push({ id: 101, name: "Marc Demo" }); + // chat expected to be found in the sidebar + this.data['mail.channel'].records.push({ + channel_type: 'chat', // testing a chat is the goal of the test + custom_channel_name: "Marc", // testing a custom name is the goal of the test + members: [this.data.currentPartnerId, 101], // expected partners + public: 'private', // expected value for testing a chat + }); + await this.start(); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Marc", + "chat should have custom name as name" + ); +}); + +QUnit.test('sidebar: rename chat', async function (assert) { + assert.expect(8); + + // expected correspondent, with a random unique id that will be used to link + // partner to chat, and a random name not used in the scope of this test but set for consistency + this.data['res.partner'].records.push({ id: 101, name: "Marc Demo" }); + // chat expected to be found in the sidebar + this.data['mail.channel'].records.push({ + channel_type: 'chat', // testing a chat is the goal of the test + custom_channel_name: "Marc", // testing a custom name is the goal of the test + members: [this.data.currentPartnerId, 101], // expected partners + public: 'private', // expected value for testing a chat + }); + await this.start(); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Marc", + "chat should have custom name as name" + ); + assert.notOk( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat name should not be editable" + ); + + await afterNextRender(() => + chat.querySelector(`:scope .o_DiscussSidebarItem_commandRename`).click() + ); + assert.ok( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat should have editable name" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_nameInput`).length, + 1, + "chat should have editable name input" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value, + "Marc", + "editable name input should have custom chat name as value by default" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).placeholder, + "Marc Demo", + "editable name input should have partner name as placeholder" + ); + + await afterNextRender(() => { + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value = "Demo"; + const kevt = new window.KeyboardEvent('keydown', { key: "Enter" }); + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).dispatchEvent(kevt); + }); + assert.notOk( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat should no longer show editable name" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Demo", + "chat should have renamed name as name" + ); +}); + +QUnit.test('default thread rendering', async function (assert) { + assert.expect(16); + + // channel expected to be found in the sidebar, + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).length, + 1, + "should have inbox mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).length, + 1, + "should have starred mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).length, + 1, + "should have history mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have channel 20 in the sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "inbox mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in inbox" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_empty + `).textContent.trim(), + "Congratulations, your inbox is empty New messages appear here." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).classList.contains('o-active'), + "starred mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_empty + `).textContent.trim(), + "No starred messages You can mark any message as 'starred', and it shows up in this mailbox." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "history mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_MessageList_empty`).textContent.trim(), + "No history messages Messages marked as read will appear in the history." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).classList.contains('o-active'), + "channel 20 should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_empty + `).textContent.trim(), + "There are no messages in this conversation." + ); +}); + +QUnit.test('initially load messages from inbox', async function (assert) { + assert.expect(4); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.step('message_fetch'); + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + assert.deepEqual( + args.kwargs.domain, + [["needaction", "=", true]], + "should fetch needaction messages" + ); + } + return this._super(...arguments); + }, + }); + assert.verifySteps(['message_fetch']); +}); + +QUnit.test('default select thread in discuss params', async function (assert) { + assert.expect(1); + + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_starred', + }, + } + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active" + ); +}); + +QUnit.test('auto-select thread in discuss context', async function (assert) { + assert.expect(1); + + await this.start({ + discuss: { + context: { + active_id: 'mail.box_starred', + }, + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active" + ); +}); + +QUnit.test('load single message from channel initially', async function (assert) { + assert.expect(7); + + // channel expected to be rendered, with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + model: 'mail.channel', + res_id: 20, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + assert.deepEqual( + args.kwargs.domain, + [["channel_ids", "in", [20]]], + "should fetch messages from channel" + ); + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_ThreadView_messageList`).length, + 1, + "should have list of messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_separatorDate`).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_MessageList_separatorLabelDate`).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 1, + "should have a single message" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `).length, + 1, + "should have message with Id 100" + ); +}); + +QUnit.test('open channel from active_id as channel id', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + discuss: { + context: { + active_id: 20, + }, + } + }); + assert.containsOnce( + document.body, + ` + .o_Discuss_thread[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ id: 20, model: 'mail.channel' }).localId + }"] + `, + "should have channel with ID 20 open in Discuss when providing active_id 20" + ); +}); + +QUnit.test('basic rendering of message', async function (assert) { + // AKU TODO: should be in message-only tests + assert.expect(13); + + // channel expected to be rendered, with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + // partner to be set as author, with a random unique id that will be used to + // link message and a random name that will be asserted in the test + this.data['res.partner'].records.push({ id: 11, name: "Demo" }); + this.data['mail.message'].records.push({ + author_id: 11, + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + model: 'mail.channel', + res_id: 20, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + const message = document.querySelector(` + .o_Discuss_thread + .o_ThreadView_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_sidebar`).length, + 1, + "should have message sidebar of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_authorAvatar`).length, + 1, + "should have author avatar in sidebar of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorAvatar`).dataset.src, + "/web/image/res.partner/11/image_128", + "should have url of message in author avatar sidebar" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_core`).length, + 1, + "should have core part of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header`).length, + 1, + "should have header in core part of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_authorName`).length, + 1, + "should have author name in header of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorName`).textContent, + "Demo", + "should have textually author name in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_date`).length, + 1, + "should have date in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_commands`).length, + 1, + "should have commands in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_command`).length, + 1, + "should have a single command in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "should have command to star message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "should have content in core part of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_content`).textContent.trim(), + "body", + "should have body of message in content part of message" + ); +}); + +QUnit.test('basic rendering of squashed message', async function (assert) { + // messages are squashed when "close", e.g. less than 1 minute has elapsed + // from messages of same author and same thread. Note that this should + // be working in non-mailboxes + // AKU TODO: should be message and/or message list-only tests + assert.expect(12); + + // channel expected to be rendered, with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + // partner to be set as author, with a random unique id that will be used to link message + this.data['res.partner'].records.push({ id: 11 }); + this.data['mail.message'].records.push( + { + author_id: 11, // must be same author as other message + body: "<p>body1</p>", // random body, set for consistency + channel_ids: [20], // to link message to channel + date: "2019-04-20 10:00:00", // date must be within 1 min from other message + id: 100, // random unique id, will be referenced in the test + message_type: 'comment', // must be a squash-able type- + model: 'mail.channel', // to link message to channel + res_id: 20, // id of related channel + }, + { + author_id: 11, // must be same author as other message + body: "<p>body2</p>", // random body, will be asserted in the test + channel_ids: [20], // to link message to channel + date: "2019-04-20 10:00:30", // date must be within 1 min from other message + id: 101, // random unique id, will be referenced in the test + message_type: 'comment', // must be a squash-able type + model: 'mail.channel', // to link message to channel + res_id: 20, // id of related channel + } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 2, + "should have 2 messages" + ); + const message1 = document.querySelector(` + .o_Discuss_thread + .o_ThreadView_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `); + const message2 = document.querySelector(` + .o_Discuss_thread + .o_ThreadView_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + `); + assert.notOk( + message1.classList.contains('o-squashed'), + "message 1 should not be squashed" + ); + assert.notOk( + message1.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'), + "message 1 should not have squashed sidebar" + ); + assert.ok( + message2.classList.contains('o-squashed'), + "message 2 should be squashed" + ); + assert.ok( + message2.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'), + "message 2 should have squashed sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_date`).length, + 1, + "message 2 should have date in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commands`).length, + 1, + "message 2 should have some commands in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commandStar`).length, + 1, + "message 2 should have star command in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_core`).length, + 1, + "message 2 should have core part" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_header`).length, + 0, + "message 2 should have a header in core part" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "message 2 should have some content in core part" + ); + assert.strictEqual( + message2.querySelector(`:scope .o_Message_content`).textContent.trim(), + "body2", + "message 2 should have body in content part" + ); +}); + +QUnit.test('inbox messages are never squashed', async function (assert) { + assert.expect(3); + + // partner to be set as author, with a random unique id that will be used to link message + this.data['res.partner'].records.push({ id: 11 }); + this.data['mail.message'].records.push( + { + author_id: 11, // must be same author as other message + body: "<p>body1</p>", // random body, set for consistency + channel_ids: [20], // to link message to channel + date: "2019-04-20 10:00:00", // date must be within 1 min from other message + id: 100, // random unique id, will be referenced in the test + message_type: 'comment', // must be a squash-able type- + model: 'mail.channel', // to link message to channel + needaction: true, // necessary for message_fetch domain + needaction_partner_ids: [this.data.currentPartnerId], // for consistency + res_id: 20, // id of related channel + }, + { + author_id: 11, // must be same author as other message + body: "<p>body2</p>", // random body, will be asserted in the test + channel_ids: [20], // to link message to channel + date: "2019-04-20 10:00:30", // date must be within 1 min from other message + id: 101, // random unique id, will be referenced in the test + message_type: 'comment', // must be a squash-able type + model: 'mail.channel', // to link message to channel + needaction: true, // necessary for message_fetch domain + needaction_partner_ids: [this.data.currentPartnerId], // for consistency + res_id: 20, // id of related channel + } + ); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 2, + "should have 2 messages" + ); + const message1 = document.querySelector(` + .o_Discuss_thread + .o_ThreadView_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `); + const message2 = document.querySelector(` + .o_Discuss_thread + .o_ThreadView_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + `); + assert.notOk( + message1.classList.contains('o-squashed'), + "message 1 should not be squashed" + ); + assert.notOk( + message2.classList.contains('o-squashed'), + "message 2 should not be squashed" + ); +}); + +QUnit.test('load all messages from channel initially, less than fetch limit (29 < 30)', async function (assert) { + // AKU TODO: thread specific test + assert.expect(5); + + // channel expected to be rendered, with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + // partner to be set as author, with a random unique id that will be used to link message + this.data['res.partner'].records.push({ id: 11 }); + for (let i = 28; i >= 0; i--) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + date: "2019-04-20 10:00:00", + model: 'mail.channel', + res_id: 20, + }); + } + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.strictEqual(args.kwargs.limit, 30, "should fetch up to 30 messages"); + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_separatorDate + `).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_separatorLabelDate + `).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 29, + "should have 29 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_loadMore + `).length, + 0, + "should not have load more link" + ); +}); + +QUnit.test('load more messages from channel', async function (assert) { + // AKU: thread specific test + assert.expect(6); + + // channel expected to be rendered, with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + // partner to be set as author, with a random unique id that will be used to link message + this.data['res.partner'].records.push({ id: 11 }); + for (let i = 0; i < 40; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + date: "2019-04-20 10:00:00", + model: 'mail.channel', + res_id: 20, + }); + } + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_separatorDate + `).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_separatorLabelDate + `).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 30, + "should have 30 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_loadMore + `).length, + 1, + "should have load more link" + ); + + await afterNextRender(() => + document.querySelector(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_loadMore + `).click() + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 40, + "should have 40 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_loadMore + `).length, + 0, + "should not longer have load more link (all messages loaded)" + ); +}); + +QUnit.test('auto-scroll to bottom of thread', async function (assert) { + // AKU TODO: thread specific test + assert.expect(2); + + // channel expected to be rendered, with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + for (let i = 1; i <= 25; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + model: 'mail.channel', + res_id: 20, + }); + } + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until channel 20 scrolled to its last message initially", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 25, + "should have 25 messages" + ); + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + assert.strictEqual( + messageList.scrollTop, + messageList.scrollHeight - messageList.clientHeight, + "should have scrolled to bottom of thread" + ); +}); + +QUnit.test('load more messages from channel (auto-load on scroll)', async function (assert) { + // AKU TODO: thread specific test + assert.expect(3); + + this.data['mail.channel'].records.push({ id: 20 }); + for (let i = 0; i < 40; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + model: 'mail.channel', + res_id: 20, + }); + } + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until channel 20 scrolled to its last message initially", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 30, + "should have 30 messages" + ); + + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => document.querySelector('.o_ThreadView_messageList').scrollTop = 0, + message: "should wait until channel 20 loaded more messages after scrolling to top", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'more-messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 40, + "should have 40 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Dsiscuss_thread .o_ThreadView_messageList .o_MessageList_loadMore + `).length, + 0, + "should not longer have load more link (all messages loaded)" + ); +}); + +QUnit.test('new messages separator [REQUIRE FOCUS]', async function (assert) { + // this test requires several messages so that the last message is not + // visible. This is necessary in order to display 'new messages' and not + // remove from DOM right away from seeing last message. + // AKU TODO: thread specific test + assert.expect(6); + + // Needed partner & user to allow simulation of message reception + this.data['res.partner'].records.push({ + id: 11, + name: "Foreigner partner", + }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 11, + }); + // channel expected to be rendered, with a random unique id that will be + // referenced in the test and the seen_message_id value set to last message + this.data['mail.channel'].records.push({ + id: 20, + seen_message_id: 125, + uuid: 'randomuuid', + }); + for (let i = 1; i <= 25; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + id: 100 + i, // for setting proper value for seen_message_id + model: 'mail.channel', + res_id: 20, + }); + } + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until channel 20 scrolled to its last message initially", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }, + }); + assert.containsN( + document.body, + '.o_MessageList_message', + 25, + "should have 25 messages" + ); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "should not display 'new messages' separator" + ); + // scroll to top + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop = 0; + }, + message: "should wait until channel scrolled to top", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === 0 + ); + }, + }); + // composer is focused by default, we remove that focus + document.querySelector('.o_ComposerTextInput_textarea').blur(); + // simulate receiving a message + await afterNextRender(async () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hu", + uuid: 'randomuuid', + }, + })); + + assert.containsN( + document.body, + '.o_MessageList_message', + 26, + "should have 26 messages" + ); + assert.containsOnce( + document.body, + '.o_MessageList_separatorNewMessages', + "should display 'new messages' separator" + ); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + messageList.scrollTop = messageList.scrollHeight - messageList.clientHeight; + }, + message: "should wait until channel scrolled to bottom", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); + assert.containsOnce( + document.body, + '.o_MessageList_separatorNewMessages', + "should still display 'new messages' separator as composer is not focused" + ); + + await afterNextRender(() => + document.querySelector('.o_ComposerTextInput_textarea').focus() + ); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "should no longer display 'new messages' separator (message seen)" + ); +}); + +QUnit.test('restore thread scroll position', async function (assert) { + assert.expect(6); + // channels expected to be rendered, with random unique id that will be referenced in the test + this.data['mail.channel'].records.push( + { + id: 11, + }, + { + id: 12, + }, + ); + for (let i = 1; i <= 25; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [11], + model: 'mail.channel', + res_id: 11, + }); + } + for (let i = 1; i <= 24; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [12], + model: 'mail.channel', + res_id: 12, + }); + } + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_11', + }, + }, + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until channel 11 scrolled to its last message", + predicate: ({ thread }) => { + return thread && thread.model === 'mail.channel' && thread.id === 11; + }, + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 25, + "should have 25 messages in channel 11" + ); + const initialMessageList = document.querySelector(` + .o_Discuss_thread + .o_ThreadView_messageList + `); + assert.strictEqual( + initialMessageList.scrollTop, + initialMessageList.scrollHeight - initialMessageList.clientHeight, + "should have scrolled to bottom of channel 11 initially" + ); + + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop = 0, + message: "should wait until channel 11 changed its scroll position to top", + predicate: ({ thread }) => { + return thread && thread.model === 'mail.channel' && thread.id === 11; + }, + }); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop, + 0, + "should have scrolled to top of channel 11", + ); + + // Ensure scrollIntoView of channel 12 has enough time to complete before + // going back to channel 11. Await is needed to prevent the scrollIntoView + // initially planned for channel 12 to actually apply on channel 11. + // task-2333535 + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + // select channel 12 + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }).localId + }"] + `).click(); + }, + message: "should wait until channel 12 scrolled to its last message", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 12 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message + `).length, + 24, + "should have 24 messages in channel 12" + ); + + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + // select channel 11 + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }).localId + }"] + `).click(); + }, + message: "should wait until channel 11 restored its scroll position", + predicate: ({ scrollTop, thread }) => { + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 11 && + scrollTop === 0 + ); + }, + }); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop, + 0, + "should have recovered scroll position of channel 11 (scroll to top)" + ); + + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + // select channel 12 + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }).localId + }"] + `).click(); + }, + message: "should wait until channel 12 recovered its scroll position (to bottom)", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 12 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); + const messageList = document.querySelector('.o_ThreadView_messageList'); + assert.strictEqual( + messageList.scrollTop, + messageList.scrollHeight - messageList.clientHeight, + "should have recovered scroll position of channel 12 (scroll to bottom)" + ); +}); + +QUnit.test('message origin redirect to channel', async function (assert) { + assert.expect(15); + + // channels expected to be rendered, with random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 11 }, { id: 12 }); + this.data['mail.message'].records.push( + { + body: "not empty", + channel_ids: [11, 12], + id: 100, + model: 'mail.channel', + record_name: "channel11", + res_id: 11, + }, + { + body: "not empty", + channel_ids: [11, 12], + id: 101, + model: 'mail.channel', + record_name: "channel12", + res_id: 12, + } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_11', + }, + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_thread .o_Message').length, + 2, + "should have 2 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `).length, + 1, + "should have message1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + `).length, + 1, + "should have message2 (Id 101)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + .o_Message_originThread + `).length, + 0, + "message1 should not have origin part in channel11 (same origin as channel)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + .o_Message_originThread + `).length, + 1, + "message2 should have origin part (origin is channel12 !== channel11)" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + .o_Message_originThread + `).textContent.trim(), + "(from #channel12)", + "message2 should display name of origin channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + .o_Message_originThreadLink + `).length, + 1, + "message2 should have link to redirect to origin" + ); + + // click on origin link of message2 (= channel12) + await afterNextRender(() => + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + .o_Message_originThreadLink + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel12 should be active channel on redirect from discuss app" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 2, + "should have 2 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `).length, + 1, + "should have message1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + `).length, + 1, + "should have message2 (Id 101)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + .o_Message_originThread + `).length, + 1, + "message1 should have origin thread part (= channel11 !== channel12)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + .o_Message_originThread + `).length, + 0, + "message2 should not have origin thread part in channel12 (same as current channel)" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + .o_Message_originThread + `).textContent.trim(), + "(from #channel11)", + "message1 should display name of origin channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + .o_Message_originThreadLink + `).length, + 1, + "message1 should have link to redirect to origin channel" + ); +}); + +QUnit.test('redirect to author (open chat)', async function (assert) { + assert.expect(7); + + // expected correspondent, with a random unique id that will be used to link + // partner to chat and a random name that will be asserted in the test + this.data['res.partner'].records.push({ id: 7, name: "Demo" }); + this.data['res.users'].records.push({ partner_id: 7 }); + this.data['mail.channel'].records.push( + // channel expected to be found in the sidebar + { + id: 1, // random unique id, will be referenced in the test + name: "General", // random name, will be asserted in the test + }, + // chat expected to be found in the sidebar + { + channel_type: 'chat', // testing a chat is the goal of the test + id: 10, // random unique id, will be referenced in the test + members: [this.data.currentPartnerId, 7], // expected partners + public: 'private', // expected value for testing a chat + } + ); + this.data['mail.message'].records.push( + { + author_id: 7, + body: "not empty", + channel_ids: [1], + id: 100, + model: 'mail.channel', + res_id: 1, + } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_1', + }, + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 1, + model: 'mail.channel', + }).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel 'General' should be active" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "Chat 'Demo' should not be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 1, + "should have 1 message" + ); + const msg1 = document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `); + assert.strictEqual( + msg1.querySelectorAll(`:scope .o_Message_authorAvatar`).length, + 1, + "message1 should have author image" + ); + assert.ok( + msg1.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'), + "message1 should have redirect to author" + ); + + await afterNextRender(() => + msg1.querySelector(`:scope .o_Message_authorAvatar`).click() + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 1, + model: 'mail.channel', + }).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel 'General' should become inactive after author redirection" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "chat 'Demo' should become active after author redirection" + ); +}); + +QUnit.test('sidebar quick search', async function (assert) { + // feature enables at 20 or more channels + assert.expect(6); + + for (let id = 1; id <= 20; id++) { + this.data['mail.channel'].records.push({ id, name: `channel${id}` }); + } + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 20, + "should have 20 channel items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_sidebar input.o_DiscussSidebar_quickSearch`).length, + 1, + "should have quick search in sidebar" + ); + + const quickSearch = document.querySelector(` + .o_Discuss_sidebar input.o_DiscussSidebar_quickSearch + `); + await afterNextRender(() => { + quickSearch.value = "1"; + const kevt1 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt1); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 11, + "should have filtered to 11 channel items" + ); + + await afterNextRender(() => { + quickSearch.value = "12"; + const kevt2 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt2); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 1, + "should have filtered to a single channel item" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item + `).dataset.threadLocalId, + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 12, + model: 'mail.channel', + }).localId, + "should have filtered to a single channel item with Id 12" + ); + + await afterNextRender(() => { + quickSearch.value = "123"; + const kevt3 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt3); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 0, + "should have filtered to no channel item" + ); +}); + +QUnit.test('basic control panel rendering', async function (assert) { + assert.expect(8); + + // channel expected to be found in the sidebar + // with a random unique id and name that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, name: "General" }); + await this.start(); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "Inbox", + "display inbox in the breadcrumb" + ); + const markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.isVisible( + markAllReadButton, + "should have visible button 'Mark all read' in the control panel of inbox" + ); + assert.ok( + markAllReadButton.disabled, + "should have disabled button 'Mark all read' in the control panel of inbox (no messages)" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "Starred", + "display starred in the breadcrumb" + ); + const unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.isVisible( + unstarAllButton, + "should have visible button 'Unstar all' in the control panel of starred" + ); + assert.ok( + unstarAllButton.disabled, + "should have disabled button 'Unstar all' in the control panel of starred (no messages)" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "#General", + "display general in the breadcrumb" + ); + const inviteButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonInvite`); + assert.isVisible( + inviteButton, + "should have visible button 'Invite' in the control panel of channel" + ); +}); + +QUnit.test('inbox: mark all messages as read', async function (assert) { + assert.expect(8); + + // channel expected to be found in the sidebar, + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.message'].records.push( + // first expected message + { + body: "not empty", + channel_ids: [20], // link message to channel + id: 100, // random unique id, useful to link notification + model: 'mail.channel', + // needaction needs to be set here for message_fetch domain, because + // mocked models don't have computed fields + needaction: true, + res_id: 20, + }, + // second expected message + { + body: "not empty", + channel_ids: [20], // link message to channel + id: 101, // random unique id, useful to link notification + model: 'mail.channel', + // needaction needs to be set here for message_fetch domain, because + // mocked models don't have computed fields + needaction: true, + res_id: 20, + } + ); + this.data['mail.notification'].records.push( + // notification to have first message in inbox + { + mail_message_id: 100, // id of related message + res_partner_id: this.data.currentPartnerId, // must be for current partner + }, + // notification to have second message in inbox + { + mail_message_id: 101, // id of related message + res_partner_id: this.data.currentPartnerId, // must be for current partner + } + ); + await this.start(); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "inbox should have counter of 2" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "channel should have counter of 2" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 2, + "should have 2 messages in inbox" + ); + let markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.notOk( + markAllReadButton.disabled, + "should have enabled button 'Mark all read' in the control panel of inbox (has messages)" + ); + + await afterNextRender(() => markAllReadButton.click()); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "inbox should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "channel should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 0, + "should have no message in inbox" + ); + markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.ok( + markAllReadButton.disabled, + "should have disabled button 'Mark all read' in the control panel of inbox (no messages)" + ); +}); + +QUnit.test('starred: unstar all', async function (assert) { + assert.expect(6); + + // messages expected to be starred + this.data['mail.message'].records.push( + { body: "not empty", starred_partner_ids: [this.data.currentPartnerId] }, + { body: "not empty", starred_partner_ids: [this.data.currentPartnerId] } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_starred', + }, + }, + }); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "starred should have counter of 2" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 2, + "should have 2 messages in starred" + ); + let unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.notOk( + unstarAllButton.disabled, + "should have enabled button 'Unstar all' in the control panel of starred (has messages)" + ); + + await afterNextRender(() => unstarAllButton.click()); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 0, + "should have no message in starred" + ); + unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.ok( + unstarAllButton.disabled, + "should have disabled button 'Unstar all' in the control panel of starred (no messages)" + ); +}); + +QUnit.test('toggle_star message', async function (assert) { + assert.expect(16); + + // channel expected to be initially rendered + // with a random unique id, will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + id: 100, + model: 'mail.channel', + res_id: 20, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'toggle_message_starred') { + assert.step('rpc:toggle_message_starred'); + assert.strictEqual( + args.args[0][0], + 100, + "should have message Id in args" + ); + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should have 1 message in channel" + ); + let message = document.querySelector(`.o_Discuss .o_Message`); + assert.notOk( + message.classList.contains('o-starred'), + "message should not be starred" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "message should have star command" + ); + + await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click()); + assert.verifySteps(['rpc:toggle_message_starred']); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "1", + "starred should display a counter of 1" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should have kept 1 message in channel" + ); + message = document.querySelector(`.o_Discuss .o_Message`); + assert.ok( + message.classList.contains('o-starred'), + "message should be starred" + ); + + await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click()); + assert.verifySteps(['rpc:toggle_message_starred']); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should no longer display a counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should still have 1 message in channel" + ); + message = document.querySelector(`.o_Discuss .o_Message`); + assert.notOk( + message.classList.contains('o-starred'), + "message should no longer be starred" + ); +}); + +QUnit.test('composer state: text save and restore', async function (assert) { + assert.expect(2); + + // channels expected to be found in the sidebar, + // with random unique id and name that will be referenced in the test + this.data['mail.channel'].records.push( + { id: 20, name: "General" }, + { id: 21, name: "Special" } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + // Write text in composer for #general + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "A message"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('input')); + }); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click() + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "An other message"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('input')); + }); + // Switch back to #general + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="General"]`).click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "A message", + "should restore the input text" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "An other message", + "should restore the input text" + ); +}); + +QUnit.test('composer state: attachments save and restore', async function (assert) { + assert.expect(6); + + // channels expected to be found in the sidebar + // with random unique id and name that will be referenced in the test + this.data['mail.channel'].records.push( + { id: 20, name: "General" }, + { id: 21, name: "Special" } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + const channels = document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item + `); + // Add attachment in a message for #general + await afterNextRender(async () => { + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ); + }); + // Switch to #special + await afterNextRender(() => channels[1].click()); + // Add attachments in a message for #special + const files = [ + await createFile({ + content: 'hello2, world', + contentType: 'text/plain', + name: 'text2.txt', + }), + await createFile({ + content: 'hello3, world', + contentType: 'text/plain', + name: 'text3.txt', + }), + await createFile({ + content: 'hello4, world', + contentType: 'text/plain', + name: 'text4.txt', + }), + ]; + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + files + ) + ); + // Switch back to #general + await afterNextRender(() => channels[0].click()); + // Check attachment is reloaded + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 1, + "should have 1 attachment in the composer" + ); + assert.strictEqual( + document.querySelector(`.o_Composer .o_Attachment`).dataset.attachmentLocalId, + this.env.models['mail.attachment'].findFromIdentifyingData({ id: 1 }).localId, + "should have correct 1st attachment in the composer" + ); + + // Switch back to #special + await afterNextRender(() => channels[1].click()); + // Check attachments are reloaded + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 3, + "should have 3 attachments in the composer" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[0].dataset.attachmentLocalId, + this.env.models['mail.attachment'].findFromIdentifyingData({ id: 2 }).localId, + "should have attachment with id 2 as 1st attachment" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[1].dataset.attachmentLocalId, + this.env.models['mail.attachment'].findFromIdentifyingData({ id: 3 }).localId, + "should have attachment with id 3 as 2nd attachment" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[2].dataset.attachmentLocalId, + this.env.models['mail.attachment'].findFromIdentifyingData({ id: 4 }).localId, + "should have attachment with id 4 as 3rd attachment" + ); +}); + +QUnit.test('post a simple message', async function (assert) { + assert.expect(15); + + // channel expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + let postedMessageId; + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + const res = await this._super(...arguments); + if (args.method === 'message_post') { + assert.step('message_post'); + assert.strictEqual( + args.args[0], + 20, + "should post message to channel Id 20" + ); + assert.strictEqual( + args.kwargs.body, + "Test", + "should post with provided content in composer input" + ); + assert.strictEqual( + args.kwargs.message_type, + "comment", + "should set message type as 'comment'" + ); + assert.strictEqual( + args.kwargs.subtype_xmlid, + "mail.mt_comment", + "should set subtype_xmlid as 'comment'" + ); + postedMessageId = res; + } + return res; + }, + }); + assert.strictEqual( + document.querySelectorAll(`.o_MessageList_empty`).length, + 1, + "should display thread with no message initially" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 0, + "should display no message initially" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have empty content initially" + ); + + // insert some HTML in editable + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Test", + "should have inserted text in editable" + ); + + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 1, + "should display a message after posting message" + ); + const message = document.querySelector(`.o_Message`); + assert.strictEqual( + message.dataset.messageLocalId, + this.env.models['mail.message'].findFromIdentifyingData({ id: postedMessageId }).localId, + "new message in thread should be linked to newly created message from message post" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorName`).textContent, + "Mitchell Admin", + "new message in thread should be from current partner name" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_content`).textContent, + "Test", + "new message in thread should have content typed from composer text input" + ); +}); + +QUnit.test('post message on non-mailing channel with "Enter" keyboard shortcut', async function (assert) { + assert.expect(2); + + // channel expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: false }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should not have any message initially in channel" + ); + + // insert some HTML in editable + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + await afterNextRender(() => { + const kevt = new window.KeyboardEvent('keydown', { key: "Enter" }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + }); + assert.containsOnce( + document.body, + '.o_Message', + "should now have single message in channel after posting message from pressing 'Enter' in text input of composer" + ); +}); + +QUnit.test('do not post message on non-mailing channel with "SHIFT-Enter" keyboard shortcut', async function (assert) { + // Note that test doesn't assert SHIFT-Enter makes a newline, because this + // default browser cannot be simulated with just dispatching + // programmatically crafted events... + assert.expect(2); + + // channel expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: true }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should not have any message initially in channel" + ); + + // insert some HTML in editable + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + const kevt = new window.KeyboardEvent('keydown', { key: "Enter", shiftKey: true }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + await nextAnimationFrame(); + assert.containsNone( + document.body, + '.o_Message', + "should still not have any message in channel after pressing 'Shift-Enter' in text input of composer" + ); +}); + +QUnit.test('post message on mailing channel with "CTRL-Enter" keyboard shortcut', async function (assert) { + assert.expect(2); + + // channel expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: true }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should not have any message initially in channel" + ); + + // insert some HTML in editable + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + await afterNextRender(() => { + const kevt = new window.KeyboardEvent('keydown', { ctrlKey: true, key: "Enter" }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + }); + assert.containsOnce( + document.body, + '.o_Message', + "should now have single message in channel after posting message from pressing 'CTRL-Enter' in text input of composer" + ); +}); + +QUnit.test('post message on mailing channel with "META-Enter" keyboard shortcut', async function (assert) { + assert.expect(2); + + // channel expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: true }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should not have any message initially in channel" + ); + + // insert some HTML in editable + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + await afterNextRender(() => { + const kevt = new window.KeyboardEvent('keydown', { key: "Enter", metaKey: true }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + }); + assert.containsOnce( + document.body, + '.o_Message', + "should now have single message in channel after posting message from pressing 'META-Enter' in text input of composer" + ); +}); + +QUnit.test('do not post message on mailing channel with "Enter" keyboard shortcut', async function (assert) { + // Note that test doesn't assert Enter makes a newline, because this + // default browser cannot be simulated with just dispatching + // programmatically crafted events... + assert.expect(2); + + // channel expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, mass_mailing: true }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "should not have any message initially in mailing channel" + ); + + // insert some HTML in editable + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + }); + const kevt = new window.KeyboardEvent('keydown', { key: "Enter" }); + document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt); + await nextAnimationFrame(); + assert.containsNone( + document.body, + '.o_Message', + "should still not have any message in mailing channel after pressing 'Enter' in text input of composer" + ); +}); + +QUnit.test('rendering of inbox message', async function (assert) { + // AKU TODO: kinda message specific test + assert.expect(7); + + this.data['mail.message'].records.push({ + body: "not empty", + model: 'res.partner', // random existing model + needaction: true, // for message_fetch domain + needaction_partner_ids: [this.data.currentPartnerId], // for consistency + record_name: 'Refactoring', // random name, will be asserted in the test + res_id: 20, // random related id + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a message" + ); + const message = document.querySelector('.o_Message'); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_originThread`).length, + 1, + "should display origin thread of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_originThread`).textContent, + " on Refactoring", + "should display origin thread name" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_command`).length, + 3, + "should display 3 commands" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "should display star command" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandReply`).length, + 1, + "should display reply command" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandMarkAsRead`).length, + 1, + "should display mark as read command" + ); +}); + +QUnit.test('mark channel as seen on last message visible [REQUIRE FOCUS]', async function (assert) { + assert.expect(3); + + // channel expected to be found in the sidebar, with the expected message_unread_counter + // and a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 10, message_unread_counter: 1 }); + this.data['mail.message'].records.push({ + id: 12, + body: "not empty", + channel_ids: [10], + model: 'mail.channel', + res_id: 10, + }); + await this.start(); + assert.containsOnce( + document.body, + `.o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"]`, + "should have discuss sidebar item with the channel" + ); + assert.hasClass( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `), + 'o-unread', + "sidebar item of channel ID 10 should be unread" + ); + + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).click(); + }, + message: "should wait until last seen by current partner message id changed", + predicate: ({ thread }) => { + return ( + thread.id === 10 && + thread.model === 'mail.channel' && + thread.lastSeenByCurrentPartnerMessageId === 12 + ); + }, + })); + assert.doesNotHaveClass( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `), + 'o-unread', + "sidebar item of channel ID 10 should not longer be unread" + ); +}); + +QUnit.test('receive new needaction messages', async function (assert) { + assert.expect(12); + + await this.start(); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `), + "should have inbox in sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "inbox should be current discuss thread" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `), + "inbox item in sidebar should not have any counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 0, + "should have no messages in inbox initially" + ); + + // simulate receiving a new needaction message + await afterNextRender(() => { + const data = { + body: "not empty", + id: 100, + needaction_partner_ids: [3], + model: 'res.partner', + res_id: 20, + }; + const notifications = [[['my-db', 'ir.needaction', 3], data]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `), + "inbox item in sidebar should now have counter" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + '1', + "inbox item in sidebar should have counter of '1'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 1, + "should have one message in inbox" + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_Message`).dataset.messageLocalId, + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId, + "should display newly received needaction message" + ); + + // simulate receiving another new needaction message + await afterNextRender(() => { + const data2 = { + body: "not empty", + id: 101, + needaction_partner_ids: [3], + model: 'res.partner', + res_id: 20, + }; + const notifications2 = [[['my-db', 'ir.needaction', 3], data2]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications2); + }); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + '2', + "inbox item in sidebar should have counter of '2'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 2, + "should have 2 messages in inbox" + ); + assert.ok( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `), + "should still display 1st needaction message" + ); + assert.ok( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId + }"] + `), + "should display 2nd needaction message" + ); +}); + +QUnit.test('reply to message from inbox (message linked to document)', async function (assert) { + assert.expect(19); + + // message that is expected to be found in Inbox + this.data['mail.message'].records.push({ + body: "<p>Test</p>", + date: "2019-04-20 11:00:00", + id: 100, // random unique id, will be used to link notification to message + message_type: 'comment', + // needaction needs to be set here for message_fetch domain, because + // mocked models don't have computed fields + needaction: true, + model: 'res.partner', + record_name: 'Refactoring', + res_id: 20, + }); + // notification to have message in Inbox + this.data['mail.notification'].records.push({ + mail_message_id: 100, // id of related message + res_partner_id: this.data.currentPartnerId, // must be for current partner + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + assert.strictEqual( + args.model, + 'res.partner', + "should post message to record with model 'res.partner'" + ); + assert.strictEqual( + args.args[0], + 20, + "should post message to record with Id 20" + ); + assert.strictEqual( + args.kwargs.body, + "Test", + "should post with provided content in composer input" + ); + assert.strictEqual( + args.kwargs.message_type, + "comment", + "should set message type as 'comment'" + ); + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a single message" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId, + "should display message with ID 100" + ); + assert.strictEqual( + document.querySelector('.o_Message_originThread').textContent, + " on Refactoring", + "should display message originates from record 'Refactoring'" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.ok( + document.querySelector('.o_Message').classList.contains('o-selected'), + "message should be selected after clicking on reply icon" + ); + assert.ok( + document.querySelector('.o_Composer'), + "should have composer after clicking on reply to message" + ); + assert.strictEqual( + document.querySelector(`.o_Composer_threadName`).textContent, + " on: Refactoring", + "composer should display origin thread name of message" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer text input should be auto-focus" + ); + + await afterNextRender(() => + document.execCommand('insertText', false, "Test") + ); + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.verifySteps(['message_post']); + assert.notOk( + document.querySelector('.o_Composer'), + "should no longer have composer after posting reply to message" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should still display a single message after posting reply" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId, + "should still display message with ID 100 after posting reply" + ); + assert.notOk( + document.querySelector('.o_Message').classList.contains('o-selected'), + "message should not longer be selected after posting reply" + ); + assert.ok( + document.querySelector('.o_notification'), + "should display a notification after posting reply" + ); + assert.strictEqual( + document.querySelector('.o_notification_content').textContent, + "Message posted on \"Refactoring\"", + "notification should tell that message has been posted to the record 'Refactoring'" + ); +}); + +QUnit.test('load recent messages from thread (already loaded some old messages)', async function (assert) { + assert.expect(6); + + // channel expected to be found in the sidebar, + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + for (let i = 0; i < 50; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], // id of related channel + id: 100 + i, // random unique id, will be referenced in the test + model: 'mail.channel', // expected value to link message to channel + // needaction needs to be set here for message_fetch domain, because + // mocked models don't have computed fields + needaction: i === 0, + // the goal is to have only the first (oldest) message in Inbox + needaction_partner_ids: i === 0 ? [this.data.currentPartnerId] : [], + res_id: 20, // id of related channel + }); + } + await this.start(); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "Inbox should have a single message initially" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId, + "the only message initially should be the one marked as 'needaction'" + ); + + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).click(); + }, + message: "should wait until channel scrolled to bottom after opening it from the discuss sidebar", + predicate: ({ scrollTop, thread }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + thread && + thread.model === 'mail.channel' && + thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 31, + `should display 31 messages inside the channel after clicking on it (the previously known + message from Inbox and the 30 most recent messages that have been fetched)` + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `).length, + 1, + "should display the message from Inbox inside the channel too" + ); + + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => document.querySelector('.o_Discuss_thread .o_ThreadView_messageList').scrollTop = 0, + message: "should wait until channel 20 loaded more messages after scrolling to top", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'more-messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 50, + "should display 50 messages inside the channel after scrolling to load more (all messages fetched)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId + }"] + `).length, + 1, + "should still display the message from Inbox inside the channel too" + ); +}); + +QUnit.test('messages marked as read move to "History" mailbox', async function (assert) { + assert.expect(10); + + // channel expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20 }); + // expected messages + this.data['mail.message'].records.push( + { + body: "not empty", + id: 100, // random unique id, useful to link notification + model: 'mail.channel', // value to link message to channel + // needaction needs to be set here for message_fetch domain, because + // mocked models don't have computed fields + needaction: true, + res_id: 20, // id of related channel + }, + { + body: "not empty", + id: 101, // random unique id, useful to link notification + model: 'mail.channel', // value to link message to channel + // needaction needs to be set here for message_fetch domain, because + // mocked models don't have computed fields + needaction: true, + res_id: 20, // id of related channel + } + ); + this.data['mail.notification'].records.push( + // notification to have first message in inbox + { + mail_message_id: 100, // id of related message + res_partner_id: this.data.currentPartnerId, // must be for current partner + }, + // notification to have second message in inbox + { + mail_message_id: 101, // id of related message + res_partner_id: this.data.currentPartnerId, // must be for current partner + } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_history', + }, + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "history mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 1, + "should have empty thread in history" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "inbox mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 0, + "inbox mailbox should not be empty" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 2, + "inbox mailbox should have 2 messages" + ); + + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead').click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "inbox mailbox should still be active after mark as read" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 1, + "inbox mailbox should now be empty after mark as read" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "history mailbox should be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 0, + "history mailbox should not be empty after mark as read" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 2, + "history mailbox should have 2 messages" + ); +}); + +QUnit.test('mark a single message as read should only move this message to "History" mailbox', async function (assert) { + assert.expect(9); + + this.data['mail.message'].records.push( + { + body: "not empty", + id: 1, + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + }, + { + body: "not empty", + id: 2, + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + } + ); + this.data['mail.notification'].records.push( + { + mail_message_id: 1, + res_partner_id: this.data.currentPartnerId, + }, + { + mail_message_id: 2, + res_partner_id: this.data.currentPartnerId, + } + ); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_history', + }, + }, + }); + assert.hasClass( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `), + 'o-active', + "history mailbox should initially be the active thread" + ); + assert.containsOnce( + document.body, + '.o_MessageList_empty', + "history mailbox should initially be empty" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).click() + ); + assert.hasClass( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `), + 'o-active', + "inbox mailbox should be active thread after clicking on it" + ); + assert.containsN( + document.body, + '.o_Message', + 2, + "inbox mailbox should have 2 messages" + ); + + await afterNextRender(() => + document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 1 }).localId + }"] .o_Message_commandMarkAsRead + `).click() + ); + assert.containsOnce( + document.body, + '.o_Message', + "inbox mailbox should have one less message after clicking mark as read" + ); + assert.containsOnce( + document.body, + `.o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 2 }).localId + }"]`, + "message still in inbox should be the one not marked as read" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click() + ); + assert.hasClass( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `), + 'o-active', + "history mailbox should be active after clicking on it" + ); + assert.containsOnce( + document.body, + '.o_Message', + "history mailbox should have only 1 message after mark as read" + ); + assert.containsOnce( + document.body, + `.o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 1 }).localId + }"]`, + "message moved in history should be the one marked as read" + ); +}); + +QUnit.test('all messages in "Inbox" in "History" after marked all as read', async function (assert) { + assert.expect(2); + + const messageOffset = 200; + for (let id = messageOffset; id < messageOffset + 40; id++) { + // message expected to be found in Inbox + this.data['mail.message'].records.push({ + body: "not empty", + id, // will be used to link notification to message + // needaction needs to be set here for message_fetch domain, because + // mocked models don't have computed fields + needaction: true, + }); + // notification to have message in Inbox + this.data['mail.notification'].records.push({ + mail_message_id: id, // id of related message + res_partner_id: this.data.currentPartnerId, // must be for current partner + }); + + } + await this.start({ + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until inbox scrolled to its last message initially", + predicate: ({ orderedMessages, scrollTop, thread }) => { + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + return ( + thread && + thread.model === 'mail.box' && + thread.id === 'inbox' && + orderedMessages.length === 30 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }, + }); + + await afterNextRender(async () => { + const markAllReadButton = document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead'); + markAllReadButton.click(); + }); + assert.containsNone( + document.body, + '.o_Message', + "there should no message in Inbox anymore" + ); + + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + document.querySelector(` + .o_DiscussSidebarItem[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click(); + }, + message: "should wait until history scrolled to its last message after opening it from the discuss sidebar", + predicate: ({ orderedMessages, scrollTop, thread }) => { + const messageList = document.querySelector('.o_MessageList'); + return ( + thread && + thread.model === 'mail.box' && + thread.id === 'history' && + orderedMessages.length === 30 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); + + // simulate a scroll to top to load more messages + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => document.querySelector('.o_MessageList').scrollTop = 0, + message: "should wait until mailbox history loaded more messages after scrolling to top", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'more-messages-loaded' && + threadViewer.thread.model === 'mail.box' && + threadViewer.thread.id === 'history' + ); + }, + }); + assert.containsN( + document.body, + '.o_Message', + 40, + "there should be 40 messages in History" + ); +}); + +QUnit.test('receive new chat message: out of odoo focus (notification, channel)', async function (assert) { + assert.expect(4); + + // channel expected to be found in the sidebar + // with a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ id: 20, channel_type: 'chat' }); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + assert.strictEqual(payload.title, "1 Message"); + }); + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message with odoo focused + await afterNextRender(() => { + const messageData = { + channel_ids: [20], + id: 126, + model: 'mail.channel', + res_id: 20, + }; + const notifications = [[['my-db', 'mail.channel', 20], messageData]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('receive new chat message: out of odoo focus (notification, chat)', async function (assert) { + assert.expect(4); + + // chat expected to be found in the sidebar with the proper channel_type + // and a random unique id that will be referenced in the test + this.data['mail.channel'].records.push({ channel_type: "chat", id: 10 }); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + assert.strictEqual(payload.title, "1 Message"); + }); + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message with odoo focused + await afterNextRender(() => { + const messageData = { + channel_ids: [10], + id: 126, + model: 'mail.channel', + res_id: 10, + }; + const notifications = [[['my-db', 'mail.channel', 10], messageData]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('receive new chat messages: out of odoo focus (tab title)', async function (assert) { + assert.expect(12); + + let step = 0; + // channel and chat expected to be found in the sidebar + // with random unique id and name that will be referenced in the test + this.data['mail.channel'].records.push( + { channel_type: 'chat', id: 20, public: 'private' }, + { channel_type: 'chat', id: 10, public: 'private' }, + ); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + step++; + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + if (step === 1) { + assert.strictEqual(payload.title, "1 Message"); + } + if (step === 2) { + assert.strictEqual(payload.title, "2 Messages"); + } + if (step === 3) { + assert.strictEqual(payload.title, "3 Messages"); + } + }); + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message in chat 20 with odoo focused + await afterNextRender(() => { + const messageData1 = { + channel_ids: [20], + id: 126, + model: 'mail.channel', + res_id: 20, + }; + const notifications1 = [[['my-db', 'mail.channel', 20], messageData1]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications1); + }); + assert.verifySteps(['set_title_part']); + + // simulate receiving a new message in chat 10 with odoo focused + await afterNextRender(() => { + const messageData2 = { + channel_ids: [10], + id: 127, + model: 'mail.channel', + res_id: 10, + }; + const notifications2 = [[['my-db', 'mail.channel', 10], messageData2]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications2); + }); + assert.verifySteps(['set_title_part']); + + // simulate receiving another new message in chat 10 with odoo focused + await afterNextRender(() => { + const messageData3 = { + channel_ids: [10], + id: 128, + model: 'mail.channel', + res_id: 10, + }; + const notifications3 = [[['my-db', 'mail.channel', 10], messageData3]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications3); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('auto-focus composer on opening thread', async function (assert) { + assert.expect(14); + + // expected correspondent, with a random unique id that will be used to link + // partner to chat and a random name that will be asserted in the test + this.data['res.partner'].records.push({ id: 7, name: "Demo User" }); + this.data['mail.channel'].records.push( + // channel expected to be found in the sidebar + { + id: 20, // random unique id, will be referenced in the test + name: "General", // random name, will be asserted in the test + }, + // chat expected to be found in the sidebar + { + channel_type: 'chat', // testing a chat is the goal of the test + id: 10, // random unique id, will be referenced in the test + members: [this.data.currentPartnerId, 7], // expected partners + public: 'private', // expected value for testing a chat + } + ); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="Inbox"] + `).length, + 1, + "should have mailbox 'Inbox' in the sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Inbox"] + `).classList.contains('o-active'), + "mailbox 'Inbox' should be active initially" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).length, + 1, + "should have channel 'General' in the sidebar" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).classList.contains('o-active'), + "channel 'General' should not be active initially" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).length, + 1, + "should have chat 'Demo User' in the sidebar" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).classList.contains('o-active'), + "chat 'Demo User' should not be active initially" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 0, + "there should be no composer when active thread of discuss is mailbox 'Inbox'" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_item[data-thread-name="General"]`).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).classList.contains('o-active'), + "channel 'General' should become active after selecting it from the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 1, + "there should be a composer when active thread of discuss is channel 'General'" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of channel 'General' should be automatically focused on opening" + ); + + document.querySelector(`.o_ComposerTextInput_textarea`).blur(); + assert.notOk( + document.activeElement === document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of channel 'General' should no longer focused on click away" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_item[data-thread-name="Demo User"]`).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).classList.contains('o-active'), + "chat 'Demo User' should become active after selecting it from the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 1, + "there should be a composer when active thread of discuss is chat 'Demo User'" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of chat 'Demo User' should be automatically focused on opening" + ); +}); + +QUnit.test('mark channel as seen if last message is visible when switching channels when the previous channel had a more recent last message than the current channel [REQUIRE FOCUS]', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push( + { id: 10, message_unread_counter: 1, name: 'Bla' }, + { id: 11, message_unread_counter: 1, name: 'Blu' }, + ); + this.data['mail.message'].records.push({ + body: 'oldest message', + channel_ids: [10], + id: 10, + }, { + body: 'newest message', + channel_ids: [11], + id: 11, + }); + await this.start({ + discuss: { + context: { + active_id: 'mail.channel_11', + }, + }, + waitUntilEvent: { + eventName: 'o-thread-view-hint-processed', + message: "should wait until channel 11 loaded its messages initially", + predicate: ({ hint, threadViewer }) => { + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 && + hint.type === 'messages-loaded' + ); + }, + }, + }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).click(); + }, + message: "should wait until last seen by current partner message id changed", + predicate: ({ thread }) => { + return ( + thread.id === 10 && + thread.model === 'mail.channel' && + thread.lastSeenByCurrentPartnerMessageId === 10 + ); + }, + })); + assert.doesNotHaveClass( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `), + 'o-unread', + "sidebar item of channel ID 10 should no longer be unread" + ); +}); + +QUnit.test('add custom filter should filter messages accordingly to selected filter', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ + id: 20, + name: "General" + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + const domainsAsStr = args.kwargs.domain.map(domain => domain.join('')); + assert.step(`message_fetch:${domainsAsStr.join(',')}`); + } + return this._super(...arguments); + }, + }); + assert.verifySteps(['message_fetch:needaction=true'], "A message_fetch request should have been done for needaction messages as inbox is selected by default"); + + // Open filter menu of control panel and select a custom filter (id = 0, the only one available) + await toggleFilterMenu(document.body); + await toggleAddCustomFilter(document.body); + await applyFilter(document.body); + assert.verifySteps(['message_fetch:id=0,needaction=true'], "A message_fetch request should have been done for selected filter & domain of current thread (inbox)"); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js new file mode 100644 index 00000000..b78faa67 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js @@ -0,0 +1,95 @@ +odoo.define('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class DiscussMobileMailboxSelection extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + return { + allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state), + discussThread: this.env.messaging.discuss.thread + ? this.env.messaging.discuss.thread.__state + : undefined, + }; + }, { + compareDepth: { + allOrderedAndPinnedMailboxes: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread[]} + */ + get orderedMailboxes() { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on a mailbox selection item. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const { mailboxLocalId } = ev.currentTarget.dataset; + const mailbox = this.env.models['mail.thread'].get(mailboxLocalId); + if (!mailbox) { + return; + } + mailbox.open(); + } + +} + +Object.assign(DiscussMobileMailboxSelection, { + props: {}, + template: 'mail.DiscussMobileMailboxSelection', +}); + +return DiscussMobileMailboxSelection; + +}); diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss new file mode 100644 index 00000000..b620e2f1 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussMobileMailboxSelection { + display: flex; + flex: 0 0 auto; +} + +.o_DiscussMobileMailboxSelection_button { + flex: 1 1 0; + padding: 8px; + z-index: 1; + + &.o-active { + z-index: 2; + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussMobileMailboxSelection_button { + box-shadow: 0 2px 4px gray('400'); +} diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml new file mode 100644 index 00000000..e996a203 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussMobileMailboxSelection" owl="1"> + <div class="o_DiscussMobileMailboxSelection"> + <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId"> + <button class="o_DiscussMobileMailboxSelection_button btn" + t-att-class="{ + 'btn-primary': discuss.thread === mailbox, + 'btn-secondary': discuss.thread !== mailbox, + 'o-active': discuss.thread === mailbox, + }" t-on-click="_onClick" t-att-data-mailbox-local-id="mailbox.localId" type="button" + > + <t t-esc="mailbox.name"/> + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js new file mode 100644 index 00000000..0d145c13 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js @@ -0,0 +1,130 @@ +odoo.define('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js', function (require) { +'use strict'; + +const { + afterEach, + afterNextRender, + beforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss_mobile_mailbox_selection', {}, function () { +QUnit.module('discuss_mobile_mailbox_selection_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign( + { + autoOpenDiscuss: true, + data: this.data, + env: { + browser: { + innerHeight: 640, + innerWidth: 360, + }, + device: { + isMobile: true, + }, + }, + hasDiscuss: true, + }, + params, + )); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('select another mailbox', async function (assert) { + assert.expect(7); + + await this.start(); + assert.containsOnce( + document.body, + '.o_Discuss', + "should display discuss initially" + ); + assert.hasClass( + document.querySelector('.o_Discuss'), + 'o-mobile', + "discuss should be opened in mobile mode" + ); + assert.containsOnce( + document.body, + '.o_Discuss_thread', + "discuss should display a thread initially" + ); + assert.strictEqual( + document.querySelector('.o_Discuss_thread').dataset.threadLocalId, + this.env.messaging.inbox.localId, + "inbox mailbox should be opened initially" + ); + assert.containsOnce( + document.body, + `.o_DiscussMobileMailboxSelection_button[ + data-mailbox-local-id="${this.env.messaging.starred.localId}" + ]`, + "should have a button to open starred mailbox" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussMobileMailboxSelection_button[ + data-mailbox-local-id="${this.env.messaging.starred.localId}"] + `).click() + ); + assert.containsOnce( + document.body, + '.o_Discuss_thread', + "discuss should still have a thread after clicking on starred mailbox" + ); + assert.strictEqual( + document.querySelector('.o_Discuss_thread').dataset.threadLocalId, + this.env.messaging.starred.localId, + "starred mailbox should be opened after clicking on it" + ); +}); + +QUnit.test('auto-select "Inbox" when discuss had channel as active thread', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start({ + discuss: { + context: { + active_id: 20, + }, + } + }); + assert.hasClass( + document.querySelector('.o_MobileMessagingNavbar_tab[data-tab-id="channel"]'), + 'o-active', + "'channel' tab should be active initially when loading discuss with channel id as active_id" + ); + + await afterNextRender(() => document.querySelector('.o_MobileMessagingNavbar_tab[data-tab-id="mailbox"]').click()); + assert.hasClass( + document.querySelector('.o_MobileMessagingNavbar_tab[data-tab-id="mailbox"]'), + 'o-active', + "'mailbox' tab should be selected after click on mailbox tab" + ); + assert.hasClass( + document.querySelector(`.o_DiscussMobileMailboxSelection_button[data-mailbox-local-id="${ + this.env.messaging.inbox.localId + }"]`), + 'o-active', + "'Inbox' mailbox should be auto-selected after click on mailbox tab" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js new file mode 100644 index 00000000..d12d0353 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js @@ -0,0 +1,308 @@ +odoo.define('mail/static/src/components/discuss_sidebar/discuss_sidebar.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + DiscussSidebarItem: require('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class DiscussSidebar extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore( + (...args) => this._useStoreSelector(...args), + { compareDepth: this._useStoreCompareDepth() } + ); + useUpdate({ func: () => this._update() }); + /** + * Reference of the quick search input. Useful to filter channels and + * chats based on this input content. + */ + this._quickSearchInputRef = useRef('quickSearchInput'); + + // bind since passed as props + this._onAddChannelAutocompleteSelect = this._onAddChannelAutocompleteSelect.bind(this); + this._onAddChannelAutocompleteSource = this._onAddChannelAutocompleteSource.bind(this); + this._onAddChatAutocompleteSelect = this._onAddChatAutocompleteSelect.bind(this); + this._onAddChatAutocompleteSource = this._onAddChatAutocompleteSource.bind(this); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {string} + */ + get FIND_OR_CREATE_CHANNEL() { + return this.env._t("Find or create a channel..."); + } + + /** + * @returns {mail.thread[]} + */ + get orderedMailboxes() { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } + + /** + * Return the list of chats that match the quick search value input. + * + * @returns {mail.thread[]} + */ + get quickSearchPinnedAndOrderedChats() { + const allOrderedAndPinnedChats = this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'chat' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + if (!this.discuss.sidebarQuickSearchValue) { + return allOrderedAndPinnedChats; + } + const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase(); + return allOrderedAndPinnedChats.filter(chat => { + const nameVal = chat.displayName.toLowerCase(); + return nameVal.includes(qsVal); + }); + } + + /** + * Return the list of channels that match the quick search value input. + * + * @returns {mail.thread[]} + */ + get quickSearchOrderedAndPinnedMultiUserChannels() { + const allOrderedAndPinnedMultiUserChannels = this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'channel' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => { + if (c1.displayName && !c2.displayName) { + return -1; + } else if (!c1.displayName && c2.displayName) { + return 1; + } else if (c1.displayName && c2.displayName && c1.displayName !== c2.displayName) { + return c1.displayName.toLowerCase() < c2.displayName.toLowerCase() ? -1 : 1; + } else { + return c1.id - c2.id; + } + }); + if (!this.discuss.sidebarQuickSearchValue) { + return allOrderedAndPinnedMultiUserChannels; + } + const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase(); + return allOrderedAndPinnedMultiUserChannels.filter(channel => { + const nameVal = channel.displayName.toLowerCase(); + return nameVal.includes(qsVal); + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _update() { + if (!this.discuss) { + return; + } + if (this._quickSearchInputRef.el) { + this._quickSearchInputRef.el.value = this.discuss.sidebarQuickSearchValue; + } + } + + /** + * @private + * @returns {Object} + */ + _useStoreCompareDepth() { + return { + allOrderedAndPinnedChats: 1, + allOrderedAndPinnedMailboxes: 1, + allOrderedAndPinnedMultiUserChannels: 1, + }; + } + + /** + * @private + * @param {Object} props + * @returns {Object} + */ + _useStoreSelector(props) { + const discuss = this.env.messaging.discuss; + return { + allOrderedAndPinnedChats: this.quickSearchPinnedAndOrderedChats, + allOrderedAndPinnedMailboxes: this.orderedMailboxes, + allOrderedAndPinnedMultiUserChannels: this.quickSearchOrderedAndPinnedMultiUserChannels, + allPinnedChannelAmount: + this.env.models['mail.thread'] + .all(thread => + thread.isPinned && + thread.model === 'mail.channel' + ).length, + discussIsAddingChannel: discuss && discuss.isAddingChannel, + discussIsAddingChat: discuss && discuss.isAddingChat, + discussSidebarQuickSearchValue: discuss && discuss.sidebarQuickSearchValue, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onAddChannelAutocompleteSelect(ev, ui) { + this.discuss.handleAddChannelAutocompleteSelect(ev, ui); + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAddChannelAutocompleteSource(req, res) { + this.discuss.handleAddChannelAutocompleteSource(req, res); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onAddChatAutocompleteSelect(ev, ui) { + this.discuss.handleAddChatAutocompleteSelect(ev, ui); + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAddChatAutocompleteSource(req, res) { + this.discuss.handleAddChatAutocompleteSource(req, res); + } + + /** + * Called when clicking on add channel icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChannelAdd(ev) { + ev.stopPropagation(); + this.discuss.update({ isAddingChannel: true }); + } + + /** + * Called when clicking on channel title. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChannelTitle(ev) { + ev.stopPropagation(); + return this.env.bus.trigger('do-action', { + action: { + name: this.env._t("Public Channels"), + type: 'ir.actions.act_window', + res_model: 'mail.channel', + views: [[false, 'kanban'], [false, 'form']], + domain: [['public', '!=', 'private']] + }, + }); + } + + /** + * Called when clicking on add chat icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChatAdd(ev) { + ev.stopPropagation(); + this.discuss.update({ isAddingChat: true }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideAddingItem(ev) { + ev.stopPropagation(); + this.discuss.clearIsAddingItem(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onInputQuickSearch(ev) { + ev.stopPropagation(); + this.discuss.update({ + sidebarQuickSearchValue: this._quickSearchInputRef.el.value, + }); + } + +} + +Object.assign(DiscussSidebar, { + components, + props: {}, + template: 'mail.DiscussSidebar', +}); + +return DiscussSidebar; + +}); diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss new file mode 100644 index 00000000..3e49cddf --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss @@ -0,0 +1,110 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussSidebar { + display: flex; + flex-flow: column; + width: $o-mail-chat-sidebar-width; + + @include media-breakpoint-up(xl) { + width: $o-mail-chat-sidebar-width + 50px; + } +} + +.o_DiscussSidebar_group { + display: flex; + flex-flow: column; + flex: 0 0 auto; +} + +.o_DiscussSidebar_groupHeader { + display: flex; + align-items: center; + margin: 5px 0; +} + +.o_DiscussSidebar_groupHeaderItem { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right; + } + + &:last-child { + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebar_itemNew { + display: flex; + justify-content: center; +} + +.o_DiscussSidebar_itemNewInput { + flex: 1 1 auto; + margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right + 3px; + margin-right: $o-mail-discuss-sidebar-scrollbar-width; +} + +.o_DiscussSidebar_quickSearch { + border-radius: 10px; + margin: 0 $o-mail-discuss-sidebar-scrollbar-width 10px; + padding: 3px 10px; +} + +.o_DiscussSidebar_separator { + width: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussSidebar { + background-color: gray('900'); + color: gray('300'); +} + +.o_DiscussSidebar_groupHeader { + font-size: $font-size-sm; + text-transform: uppercase; + font-weight: bolder; +} + +.o_DiscussSidebar_groupHeaderItemAdd { + cursor: pointer; + + &:not(:hover) { + color: gray('600'); + } +} + +.o_DiscussSidebar_groupTitle { + + &:not(.o-clickable) { + color: gray('600'); + } + + &.o-clickable { + cursor: pointer; + + &:not(:hover) { + color: gray('600'); + } + } +} + +.o_DiscussSidebar_itemNewInput { + outline: none; +} + +.o_DiscussSidebar_quickSearch { + border: none; + outline: none; +} + +.o_DiscussSidebar_separator { + background-color: gray('600'); +} diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml new file mode 100644 index 00000000..4f9c10e5 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml @@ -0,0 +1,81 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussSidebar" owl="1"> + <div name="root" class="o_DiscussSidebar"> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupMailbox"> + <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="mailbox.localId" + /> + </t> + </div> + <hr class="o_DiscussSidebar_separator"/> + <t t-if="env.models['mail.thread'].all(thread => thread.isPinned and thread.model === 'mail.channel').length > 19"> + <input class="o_DiscussSidebar_quickSearch" t-on-input="_onInputQuickSearch" placeholder="Quick search..." t-ref="quickSearchInput" t-esc="discuss.sidebarQuickSearchValue"/> + </t> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChannel"> + <div class="o_DiscussSidebar_groupHeader"> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle o-clickable" t-on-click="_onClickChannelTitle"> + Channels + </div> + <div class="o-autogrow"/> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChannelAdd" title="Add or join a channel"/> + </div> + <div class="o_DiscussSidebar_list"> + <t t-if="discuss.isAddingChannel"> + <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew"> + <AutocompleteInput + class="o_DiscussSidebar_itemNewInput" + customClass="'o_DiscussSidebar_newChannelAutocompleteSuggestions'" + isFocusOnMount="true" + isHtml="true" + placeholder="FIND_OR_CREATE_CHANNEL" + select="_onAddChannelAutocompleteSelect" + source="_onAddChannelAutocompleteSource" + t-on-o-hide="_onHideAddingItem" + /> + </div> + </t> + <t t-foreach="quickSearchOrderedAndPinnedMultiUserChannels" t-as="channel" t-key="channel.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="channel.localId" + /> + </t> + </div> + </div> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChat"> + <div class="o_DiscussSidebar_groupHeader"> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle"> + Direct Messages + </div> + <div class="o-autogrow"/> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChatAdd" title="Start a conversation"/> + </div> + <div class="o_DiscussSidebar_list"> + <t t-if="discuss.isAddingChat"> + <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew"> + <AutocompleteInput + class="o_DiscussSidebar_itemNewInput" + isFocusOnMount="true" + placeholder="'Find or start a conversation...'" + select="_onAddChatAutocompleteSelect" + source="_onAddChatAutocompleteSource" + t-on-o-hide="_onHideAddingItem" + /> + </div> + </t> + <t t-foreach="quickSearchPinnedAndOrderedChats" t-as="chat" t-key="chat.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="chat.localId" + /> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js new file mode 100644 index 00000000..0226035c --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js @@ -0,0 +1,220 @@ +odoo.define('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', function (require) { +'use strict'; + +const components = { + EditableText: require('mail/static/src/components/editable_text/editable_text.js'), + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const { isEventHandled } = require('mail/static/src/utils/utils.js'); + +const Dialog = require('web.Dialog'); + +const { Component } = owl; + +class DiscussSidebarItem extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const discuss = this.env.messaging.discuss; + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const correspondent = thread ? thread.correspondent : undefined; + return { + correspondentName: correspondent && correspondent.name, + discussIsRenamingThread: discuss && discuss.renamingThreads.includes(thread), + isDiscussThread: discuss && discuss.thread === thread, + starred: this.env.messaging.starred, + thread, + threadChannelType: thread && thread.channel_type, + threadCounter: thread && thread.counter, + threadDisplayName: thread && thread.displayName, + threadGroupBasedSubscription: thread && thread.group_based_subscription, + threadLocalMessageUnreadCounter: thread && thread.localMessageUnreadCounter, + threadMassMailing: thread && thread.mass_mailing, + threadMessageNeedactionCounter: thread && thread.message_needaction_counter, + threadModel: thread && thread.model, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the counter of this discuss item, which is based on the thread type. + * + * @returns {integer} + */ + get counter() { + if (this.thread.model === 'mail.box') { + return this.thread.counter; + } else if (this.thread.channel_type === 'channel') { + return this.thread.message_needaction_counter; + } else if (this.thread.channel_type === 'chat') { + return this.thread.localMessageUnreadCounter; + } + return 0; + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {boolean} + */ + hasUnpin() { + return this.thread.channel_type === 'chat'; + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Promise} + */ + _askAdminConfirmation() { + return new Promise(resolve => { + Dialog.confirm(this, + this.env._t("You are the administrator of this channel. Are you sure you want to leave?"), + { + buttons: [ + { + text: this.env._t("Leave"), + classes: 'btn-primary', + close: true, + click: resolve + }, + { + text: this.env._t("Discard"), + close: true + } + ] + } + ); + }); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onCancelRenaming(ev) { + this.discuss.cancelThreadRenaming(this.thread); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (isEventHandled(ev, 'EditableText.click')) { + return; + } + this.thread.open(); + } + + /** + * Stop propagation to prevent selecting this item. + * + * @private + * @param {CustomEvent} ev + */ + _onClickedEditableText(ev) { + ev.stopPropagation(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickLeave(ev) { + ev.stopPropagation(); + if (this.thread.creator === this.env.messaging.currentUser) { + await this._askAdminConfirmation(); + } + this.thread.unsubscribe(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRename(ev) { + ev.stopPropagation(); + this.discuss.setThreadRenaming(this.thread); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSettings(ev) { + ev.stopPropagation(); + return this.env.bus.trigger('do-action', { + action: { + type: 'ir.actions.act_window', + res_model: this.thread.model, + res_id: this.thread.id, + views: [[false, 'form']], + target: 'current' + }, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnpin(ev) { + ev.stopPropagation(); + this.thread.unsubscribe(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.newName + */ + _onValidateEditableText(ev) { + ev.stopPropagation(); + this.discuss.renameThread(this.thread, ev.detail.newName); + } + +} + +Object.assign(DiscussSidebarItem, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.DiscussSidebarItem', +}); + +return DiscussSidebarItem; + +}); diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss new file mode 100644 index 00000000..aebc4b9a --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss @@ -0,0 +1,109 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussSidebarItem { + display: flex; + align-items: center; + padding: map-get($spacers, 1) 0; + + &:hover .o_DiscussSidebarItem_commands { + display: flex; + } +} + +.o_DiscussSidebarItem_activeIndicator { + width: $o-mail-discuss-sidebar-active-indicator-width; + align-self: stretch; + flex: 0 0 auto; +} + +.o_DiscussSidebarItem_command { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: 0px; + } + + &:last-child { + margin-right: 0px; + } +} + +.o_DiscussSidebarItem_commands { + display: none; +} + +.o_DiscussSidebarItem_item { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: 0px; + margin-right: $o-mail-discuss-sidebar-active-indicator-margin-right; + } + + &:last-child { + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebarItem_name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.o-editable { + margin-left: $o-mail-discuss-sidebar-active-indicator-width + $o-mail-discuss-sidebar-active-indicator-margin-right; + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebarItem_nameInput { + width: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussSidebarItem { + cursor: pointer; + + &:hover { + background-color: darken(gray('900'), 5%); + } + + &.o-starred-box { + .o_DiscussSidebarItem_counter { + border-color: gray('600'); + background-color: gray('600'); + } + } +} + +.o_DiscussSidebarItem_activeIndicator.o-item-active { + background-color: $o-brand-primary; +} + +.o_DiscussSidebarItem_command:not(:hover) { + color: gray('600'); +} + +.o_DiscussSidebarItem_counter { + background-color: $o-brand-primary; +} + +.o_DiscussSidebarItem_name { + + &.o-item-unread { + font-weight: bold; + } +} + +.o_DiscussSidebarItem_nameInput { + outline: none; + border: none; + border-radius: 2px; +} diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml new file mode 100644 index 00000000..74aace21 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussSidebarItem" owl="1"> + <div class="o_DiscussSidebarItem" + t-att-class="{ + 'o-active': thread and discuss.thread === thread, + 'o-starred-box': thread and thread === env.messaging.starred, + 'o-unread': thread and thread.localMessageUnreadCounter > 0, + }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined" t-att-data-thread-name="thread ? thread.displayName : undefined" + > + <t t-if="thread"> + <div class=" o_DiscussSidebarItem_activeIndicator o_DiscussSidebarItem_item" t-att-class="{ 'o-item-active': discuss.thread === thread }"/> + <ThreadIcon class="o_DiscussSidebarItem_item" threadLocalId="thread.localId"/> + <t t-if="thread.channel_type === 'chat' and discuss.renamingThreads.includes(thread)"> + <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name o-editable"> + <EditableText + class="o_DiscussSidebarItem_nameInput" + placeholder="thread.correspondent ? thread.correspondent.name : thread.name" + value="thread.displayName" + t-on-o-cancel="_onCancelRenaming" + t-on-o-clicked="_onClickedEditableText" + t-on-o-validate="_onValidateEditableText" + /> + </div> + </t> + <t t-else=""> + <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name" t-att-class="{ 'o-item-unread': thread.localMessageUnreadCounter > 0 }"> + <t t-esc="thread.displayName"/> + </div> + <t t-if="thread.mass_mailing"> + <i class="fa fa-envelope-o" title="Messages are sent by email" role="img"/> + </t> + </t> + <div class="o-autogrow o_DiscussSidebarItem_item"/> + <t t-if="thread.model !== 'mail.box'"> + <div class="o_DiscussSidebarItem_commands o_DiscussSidebarItem_item"> + <t t-if="thread.channel_type === 'channel'"> + <div class="fa fa-cog o_DiscussSidebarItem_command o_DiscussSidebarItem_commandSettings" t-on-click="_onClickSettings" title="Channel settings" role="img"/> + <t t-if="!thread.message_needaction_counter and !thread.group_based_subscription"> + <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandLeave fa fa-times" t-on-click="_onClickLeave" title="Leave this channel" role="img"/> + </t> + </t> + <t t-if="thread.channel_type === 'chat'"> + <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandRename fa fa-cog" t-on-click="_onClickRename" title="Rename conversation" role="img"/> + </t> + <t t-if="hasUnpin()"> + <t t-if="!thread.localMessageUnreadCounter"> + <div class="fa fa-times o_DiscussSidebarItem_command o_DiscussSidebarItem_commandUnpin" t-on-click="_onClickUnpin" title="Unpin conversation" role="img"/> + </t> + </t> + </div> + </t> + <t t-if="counter > 0"> + <div class="o_DiscussSidebarItem_counter o_DiscussSidebarItem_item badge badge-pill"> + <t t-esc="counter"/> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.js b/addons/mail/static/src/components/drop_zone/drop_zone.js new file mode 100644 index 00000000..dcbb7019 --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.js @@ -0,0 +1,139 @@ +odoo.define('mail/static/src/components/drop_zone/drop_zone.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); + +const { Component, useState } = owl; + +class DropZone extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + this.state = useState({ + /** + * Determine whether the user is dragging files over the dropzone. + * Useful to provide visual feedback in that case. + */ + isDraggingInside: false, + }); + /** + * Counts how many drag enter/leave happened on self and children. This + * ensures the drop effect stays active when dragging over a child. + */ + this._dragCount = 0; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns whether the given node is self or a children of self. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + return this.el.contains(node); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Making sure that dragging content is external files. + * Ignoring other content dragging like text. + * + * @private + * @param {DataTransfer} dataTransfer + * @returns {boolean} + */ + _isDragSourceExternalFile(dataTransfer) { + const dragDataType = dataTransfer.types; + if (dragDataType.constructor === window.DOMStringList) { + return dragDataType.contains('Files'); + } + if (dragDataType.constructor === Array) { + return dragDataType.includes('Files'); + } + return false; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Shows a visual drop effect when dragging inside the dropzone. + * + * @private + * @param {DragEvent} ev + */ + _onDragenter(ev) { + ev.preventDefault(); + if (this._dragCount === 0) { + this.state.isDraggingInside = true; + } + this._dragCount++; + } + + /** + * Hides the visual drop effect when dragging outside the dropzone. + * + * @private + * @param {DragEvent} ev + */ + _onDragleave(ev) { + this._dragCount--; + if (this._dragCount === 0) { + this.state.isDraggingInside = false; + } + } + + /** + * Prevents default (from the template) in order to receive the drop event. + * The drop effect cursor works only when set on dragover. + * + * @private + * @param {DragEvent} ev + */ + _onDragover(ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; + } + + /** + * Triggers the `o-dropzone-files-dropped` event when new files are dropped + * on the dropzone, and then removes the visual drop effect. + * + * The parents should handle this event to process the files as they wish, + * such as uploading them. + * + * @private + * @param {DragEvent} ev + */ + _onDrop(ev) { + ev.preventDefault(); + if (this._isDragSourceExternalFile(ev.dataTransfer)) { + this.trigger('o-dropzone-files-dropped', { + files: ev.dataTransfer.files, + }); + } + this.state.isDraggingInside = false; + } + +} + +Object.assign(DropZone, { + props: {}, + template: 'mail.DropZone', +}); + +return DropZone; + +}); diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.scss b/addons/mail/static/src/components/drop_zone/drop_zone.scss new file mode 100644 index 00000000..202e4ceb --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.scss @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DropZone { + display: flex; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + z-index: 1; + align-items: center; + justify-content: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DropZone { + color: $o-enterprise-primary-color; + background: rgba(255, 255, 255, 0.9); + border: 2px dashed $o-enterprise-primary-color; + + &.o-dragging-inside { + border-width: 5px; + } +} diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.xml b/addons/mail/static/src/components/drop_zone/drop_zone.xml new file mode 100644 index 00000000..b3db940f --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DropZone" owl="1"> + <div class="o_DropZone" t-att-class="{ 'o-dragging-inside': state.isDraggingInside }" t-on-dragenter="_onDragenter" t-on-dragleave="_onDragleave" t-on-dragover="_onDragover" t-on-drop="_onDrop"> + <h4> + Drag Files Here <i class="fa fa-download"/> + </h4> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/editable_text/editable_text.js b/addons/mail/static/src/components/editable_text/editable_text.js new file mode 100644 index 00000000..be7e7ddc --- /dev/null +++ b/addons/mail/static/src/components/editable_text/editable_text.js @@ -0,0 +1,91 @@ +odoo.define('mail/static/src/components/editable_text/editable_text.js', function (require) { +'use strict'; + +const { markEventHandled } = require('mail/static/src/utils/utils.js'); +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); + +const { Component } = owl; + +class EditableText extends Component { + + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + } + + mounted() { + this.el.focus(); + this.el.setSelectionRange(0, (this.el.value && this.el.value.length) || 0); + } + + willUnmount() { + this.trigger('o-cancel'); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onBlur(ev) { + this.trigger('o-cancel'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + markEventHandled(ev, 'EditableText.click'); + this.trigger('o-clicked'); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + switch (ev.key) { + case 'Enter': + this._onKeydownEnter(ev); + break; + case 'Escape': + this.trigger('o-cancel'); + break; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownEnter(ev) { + const value = this.el.value; + const newName = value || this.props.placeholder; + if (this.props.value !== newName) { + this.trigger('o-validate', { newName }); + } else { + this.trigger('o-cancel'); + } + } + +} + +Object.assign(EditableText, { + defaultProps: { + placeholder: "", + value: "", + }, + props: { + placeholder: String, + value: String, + }, + template: 'mail.EditableText', +}); + +return EditableText; + +}); diff --git a/addons/mail/static/src/components/editable_text/editable_text.xml b/addons/mail/static/src/components/editable_text/editable_text.xml new file mode 100644 index 00000000..5e3aa52a --- /dev/null +++ b/addons/mail/static/src/components/editable_text/editable_text.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.EditableText" owl="1"> + <input class="o_EditableText" t-att-value="props.value" t-on-blur="_onBlur" t-on-click="_onClick" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/> + </t> + +</templates> diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.js b/addons/mail/static/src/components/emojis_popover/emojis_popover.js new file mode 100644 index 00000000..a312eed4 --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.js @@ -0,0 +1,78 @@ +odoo.define('mail/static/src/components/emojis_popover/emojis_popover.js', function (require) { +'use strict'; + +const emojis = require('mail.emojis'); +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const { Component } = owl; + +class EmojisPopover extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + this.emojis = emojis; + useShouldUpdateBasedOnProps(); + useUpdate({ func: () => this._update() }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _update() { + this.trigger('o-popover-compute'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + close() { + this.trigger('o-popover-close'); + } + + /** + * Returns whether the given node is self or a children of self. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + if (!this.el) { + return false; + } + return this.el.contains(node); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEmoji(ev) { + this.close(); + this.trigger('o-emoji-selection', { + unicode: ev.currentTarget.dataset.unicode, + }); + } + +} + +Object.assign(EmojisPopover, { + props: {}, + template: 'mail.EmojisPopover', +}); + +return EmojisPopover; + +}); diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.scss b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss new file mode 100644 index 00000000..3a4559ae --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_EmojisPopover { + display: flex; + flex-flow: row wrap; + max-width: 200px; +} + +.o_EmojisPopover_emoji { + font-size: 1.1em; + margin: 3px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_EmojisPopover_emoji { + cursor: pointer; +} diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.xml b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml new file mode 100644 index 00000000..cac840bb --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.EmojisPopover" owl="1"> + <div class="o_EmojisPopover"> + <t t-foreach="emojis" t-as="emoji" t-key="emoji.unicode"> + <span class="o_EmojisPopover_emoji" t-on-click="_onClickEmoji" t-att-title="emoji.description" t-att-data-source="emoji.sources[0]" t-att-data-unicode="emoji.unicode"> + <t t-esc="emoji.unicode"/> + </span> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.js b/addons/mail/static/src/components/file_uploader/file_uploader.js new file mode 100644 index 00000000..4e57eadd --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.js @@ -0,0 +1,241 @@ +odoo.define('mail/static/src/components/file_uploader/file_uploader.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); + +const core = require('web.core'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class FileUploader extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this._fileInputRef = useRef('fileInput'); + this._fileUploadId = _.uniqueId('o_FileUploader_fileupload'); + this._onAttachmentUploaded = this._onAttachmentUploaded.bind(this); + useShouldUpdateBasedOnProps({ + compareDepth: { + attachmentLocalIds: 1, + newAttachmentExtraData: 3, + }, + }); + } + + mounted() { + $(window).on(this._fileUploadId, this._onAttachmentUploaded); + } + + willUnmount() { + $(window).off(this._fileUploadId); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {FileList|Array} files + * @returns {Promise} + */ + async uploadFiles(files) { + await this._unlinkExistingAttachments(files); + this._createTemporaryAttachments(files); + await this._performUpload(files); + this._fileInputRef.el.value = ''; + } + + openBrowserFileUploader() { + this._fileInputRef.el.click(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @deprecated + * @private + * @param {Object} fileData + * @returns {mail.attachment} + */ + _createAttachment(fileData) { + return this.env.models['mail.attachment'].create(Object.assign( + {}, + fileData, + this.props.newAttachmentExtraData + )); + } + + /** + * @private + * @param {File} file + * @returns {FormData} + */ + _createFormData(file) { + let formData = new window.FormData(); + formData.append('callback', this._fileUploadId); + formData.append('csrf_token', core.csrf_token); + formData.append('id', this.props.uploadId); + formData.append('model', this.props.uploadModel); + formData.append('ufile', file, file.name); + return formData; + } + + /** + * @private + * @param {FileList|Array} files + */ + _createTemporaryAttachments(files) { + for (const file of files) { + this.env.models['mail.attachment'].create( + Object.assign( + { + filename: file.name, + isTemporary: true, + name: file.name + }, + this.props.newAttachmentExtraData + ), + ); + } + } + /** + * @private + * @param {FileList|Array} files + * @returns {Promise} + */ + async _performUpload(files) { + for (const file of files) { + const uploadingAttachment = this.env.models['mail.attachment'].find(attachment => + attachment.isTemporary && + attachment.filename === file.name + ); + if (!uploadingAttachment) { + // Uploading attachment no longer exists. + // This happens when an uploading attachment is being deleted by user. + continue; + } + try { + const response = await this.env.browser.fetch('/web/binary/upload_attachment', { + method: 'POST', + body: this._createFormData(file), + signal: uploadingAttachment.uploadingAbortController.signal, + }); + let html = await response.text(); + const template = document.createElement('template'); + template.innerHTML = html.trim(); + window.eval(template.content.firstChild.textContent); + } catch (e) { + if (e.name !== 'AbortError') { + throw e; + } + } + } + } + + /** + * @private + * @param {FileList|Array} files + * @returns {Promise} + */ + async _unlinkExistingAttachments(files) { + for (const file of files) { + const attachment = this.props.attachmentLocalIds + .map(attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId)) + .find(attachment => attachment.name === file.name && attachment.size === file.size); + // if the files already exits, delete the file before upload + if (attachment) { + attachment.remove(); + } + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQuery.Event} ev + * @param {...Object} filesData + */ + async _onAttachmentUploaded(ev, ...filesData) { + for (const fileData of filesData) { + const { error, filename, id, mimetype, name, size } = fileData; + if (error || !id) { + this.env.services['notification'].notify({ + type: 'danger', + message: owl.utils.escape(error), + }); + const relatedTemporaryAttachments = this.env.models['mail.attachment'] + .find(attachment => + attachment.filename === filename && + attachment.isTemporary + ); + for (const attachment of relatedTemporaryAttachments) { + attachment.delete(); + } + return; + } + // FIXME : needed to avoid problems on uploading + // Without this the useStore selector of component could be not called + // E.g. in attachment_box_tests.js + await new Promise(resolve => setTimeout(resolve)); + const attachment = this.env.models['mail.attachment'].insert( + Object.assign( + { + filename, + id, + mimetype, + name, + size, + }, + this.props.newAttachmentExtraData + ), + ); + this.trigger('o-attachment-created', { attachment }); + } + } + + /** + * Called when there are changes in the file input. + * + * @private + * @param {Event} ev + * @param {EventTarget} ev.target + * @param {FileList|Array} ev.target.files + */ + async _onChangeAttachment(ev) { + await this.uploadFiles(ev.target.files); + } + +} + +Object.assign(FileUploader, { + defaultProps: { + uploadId: 0, + uploadModel: 'mail.compose.message' + }, + props: { + attachmentLocalIds: { + type: Array, + element: String, + }, + newAttachmentExtraData: { + type: Object, + optional: true, + }, + uploadId: Number, + uploadModel: String, + }, + template: 'mail.FileUploader', +}); + +return FileUploader; + +}); diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.scss b/addons/mail/static/src/components/file_uploader/file_uploader.scss new file mode 100644 index 00000000..32792313 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.scss @@ -0,0 +1,3 @@ +.o_FileUploader_input { + display: none !important; +} diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.xml b/addons/mail/static/src/components/file_uploader/file_uploader.xml new file mode 100644 index 00000000..bf144037 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FileUploader" owl="1"> + <div class="o_FileUploader"> + <input class="o_FileUploader_input" t-on-change="_onChangeAttachment" multiple="true" type="file" t-ref="fileInput" t-key="'fileInput'"/> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/file_uploader/file_uploader_tests.js b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js new file mode 100644 index 00000000..4bf528f1 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js @@ -0,0 +1,94 @@ +odoo.define('mail/static/src/components/file_uploader/file_uploader_tests.js', function (require) { +"use strict"; + +const components = { + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const { + file: { + createFile, + inputFiles, + }, +} = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('file_uploader', {}, function () { +QUnit.module('file_uploader_tests.js', { + beforeEach() { + beforeEach(this); + this.components = []; + + this.createFileUploaderComponent = async otherProps => { + const props = Object.assign({ attachmentLocalIds: [] }, otherProps); + return createRootComponent(this, components.FileUploader, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('no conflicts between file uploaders', async function (assert) { + assert.expect(2); + + await this.start(); + const fileUploader1 = await this.createFileUploaderComponent(); + const fileUploader2 = await this.createFileUploaderComponent(); + const file1 = await createFile({ + name: 'text1.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + inputFiles( + fileUploader1.el.querySelector('.o_FileUploader_input'), + [file1] + ); + await nextAnimationFrame(); // we can't use afterNextRender as fileInput are display:none + assert.strictEqual( + this.env.models['mail.attachment'].all().length, + 1, + 'Uploaded file should be the only attachment created' + ); + + const file2 = await createFile({ + name: 'text2.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + inputFiles( + fileUploader2.el.querySelector('.o_FileUploader_input'), + [file2] + ); + await nextAnimationFrame(); + assert.strictEqual( + this.env.models['mail.attachment'].all().length, + 2, + 'Uploaded file should be the only attachment added' + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follow_button/follow_button.js b/addons/mail/static/src/components/follow_button/follow_button.js new file mode 100644 index 00000000..3c1808cb --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.js @@ -0,0 +1,93 @@ +odoo.define('mail/static/src/components/follow_button/follow_button.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useState } = owl.hooks; + +class FollowButton extends Component { + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + this.state = useState({ + /** + * Determine whether the unfollow button is highlighted or not. + */ + isUnfollowButtonHighlighted: false, + }); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + threadIsCurrentPartnerFollowing: thread && thread.isCurrentPartnerFollowing, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @return {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollow(ev) { + this.thread.follow(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnfollow(ev) { + this.thread.unfollow(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onMouseLeaveUnfollow(ev) { + this.state.isUnfollowButtonHighlighted = false; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onMouseEnterUnfollow(ev) { + this.state.isUnfollowButtonHighlighted = true; + } + +} + +Object.assign(FollowButton, { + defaultProps: { + isDisabled: false, + }, + props: { + isDisabled: Boolean, + threadLocalId: String, + }, + template: 'mail.FollowButton', +}); + +return FollowButton; + +}); diff --git a/addons/mail/static/src/components/follow_button/follow_button.scss b/addons/mail/static/src/components/follow_button/follow_button.scss new file mode 100644 index 00000000..36fb60e7 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.scss @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowButton { + display: flex; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_FollowButton_follow { + color: gray('600'); +} + +.o_FollowButton_unfollow { + color: gray('600'); + + &.o-following { + color: $green; + } + + &.o-unfollow { + color: $orange; + } +} diff --git a/addons/mail/static/src/components/follow_button/follow_button.xml b/addons/mail/static/src/components/follow_button/follow_button.xml new file mode 100644 index 00000000..00fc8d65 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowButton" owl="1"> + <div class="o_FollowButton"> + <t t-if="thread.isCurrentPartnerFollowing"> + <button class="o_FollowButton_unfollow btn btn-link" t-att-class="{ 'o-following': !state.isUnfollowButtonHighlighted, 'o-unfollow': state.isUnfollowButtonHighlighted }" t-att-disabled="props.isDisabled" t-on-click="_onClickUnfollow" t-on-mouseenter="_onMouseEnterUnfollow" t-on-mouseleave="_onMouseLeaveUnfollow"> + <t t-if="state.isUnfollowButtonHighlighted"> + <i class="fa fa-times"/> Unfollow + </t> + <t t-else=""> + <i class="fa fa-check"/> Following + </t> + </button> + </t> + <t t-else=""> + <button class="o_FollowButton_follow btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollow"> + Follow + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follow_button/follow_button_tests.js b/addons/mail/static/src/components/follow_button/follow_button_tests.js new file mode 100644 index 00000000..0c4553c6 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button_tests.js @@ -0,0 +1,278 @@ +odoo.define('mail/static/src/components/follow_button/follow_button_tests.js', function (require) { +'use strict'; + +const components = { + FollowButton: require('mail/static/src/components/follow_button/follow_button.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follow_button', {}, function () { +QUnit.module('follow_button_tests.js', { + beforeEach() { + beforeEach(this); + + this.createFollowButtonComponent = async (thread, otherProps = {}) => { + const props = Object.assign({ threadLocalId: thread.localId }, otherProps); + await createRootComponent(this, components.FollowButton, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread, { isDisabled: true }); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have 'Follow' button" + ); + assert.ok( + document.querySelector('.o_FollowButton_follow').disabled, + "'Follow' button should be disabled" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have 'Follow' button" + ); + assert.notOk( + document.querySelector('.o_FollowButton_follow').disabled, + "'Follow' button should be disabled" + ); +}); + +QUnit.test('hover following button', async function (assert) { + assert.expect(8); + + this.data['res.partner'].records.push({ id: 100, message_follower_ids: [1] }); + this.data['mail.followers'].records.push({ + id: 1, + is_active: true, + is_editable: true, + partner_id: this.data.currentPartnerId, + res_id: 100, + res_model: 'res.partner', + }); + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + thread.follow(); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have 'Unfollow' button" + ); + assert.strictEqual( + document.querySelector('.o_FollowButton_unfollow').textContent.trim(), + 'Following', + "'unfollow' button should display 'Following' as text when not hovered" + ); + assert.containsNone( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-times', + "'unfollow' button should not contain a cross icon when not hovered" + ); + assert.containsOnce( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-check', + "'unfollow' button should contain a check icon when not hovered" + ); + + await afterNextRender(() => { + document + .querySelector('.o_FollowButton_unfollow') + .dispatchEvent(new window.MouseEvent('mouseenter')); + } + ); + assert.strictEqual( + document.querySelector('.o_FollowButton_unfollow').textContent.trim(), + 'Unfollow', + "'unfollow' button should display 'Unfollow' as text when hovered" + ); + assert.containsOnce( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-times', + "'unfollow' button should contain a cross icon when hovered" + ); + assert.containsNone( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-check', + "'unfollow' button should not contain a check icon when hovered" + ); +}); + +QUnit.test('click on "follow" button', async function (assert) { + assert.expect(7); + + this.data['res.partner'].records.push({ id: 100, message_follower_ids: [1] }); + this.data['mail.followers'].records.push({ + id: 1, + is_active: true, + is_editable: true, + partner_id: this.data.currentPartnerId, + res_id: 100, + res_model: 'res.partner', + }); + await this.start({ + async mockRPC(route, args) { + if (route.includes('message_subscribe')) { + assert.step('rpc:message_subscribe'); + } else if (route.includes('mail/read_followers')) { + assert.step('rpc:mail/read_followers'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have button follow" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowButton_follow').click(); + }); + assert.verifySteps([ + 'rpc:message_subscribe', + 'rpc:mail/read_followers', + ]); + assert.containsNone( + document.body, + '.o_FollowButton_follow', + "should not have follow button after clicked on follow" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have unfollow button after clicked on follow" + ); +}); + +QUnit.test('click on "unfollow" button', async function (assert) { + assert.expect(7); + + this.data['res.partner'].records.push({ id: 100, message_follower_ids: [1] }); + this.data['mail.followers'].records.push({ + id: 1, + is_active: true, + is_editable: true, + partner_id: this.data.currentPartnerId, + res_id: 100, + res_model: 'res.partner', + }); + await this.start({ + async mockRPC(route, args) { + if (route.includes('message_unsubscribe')) { + assert.step('rpc:message_unsubscribe'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + thread.follow(); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsNone( + document.body, + '.o_FollowButton_follow', + "should not have button follow" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have button unfollow" + ); + + await afterNextRender(() => document.querySelector('.o_FollowButton_unfollow').click()); + assert.verifySteps(['rpc:message_unsubscribe']); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have follow button after clicked on unfollow" + ); + assert.containsNone( + document.body, + '.o_FollowButton_unfollow', + "should not have unfollow button after clicked on unfollow" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower/follower.js b/addons/mail/static/src/components/follower/follower.js new file mode 100644 index 00000000..bafcd88a --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.js @@ -0,0 +1,80 @@ +odoo.define('mail/static/src/components/follower/follower.js', function (require) { +'use strict'; + +const components = { + FollowerSubtypeList: require('mail/static/src/components/follower_subtype_list/follower_subtype_list.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class Follower extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const follower = this.env.models['mail.follower'].get(props.followerLocalId); + return [follower ? follower.__state : undefined]; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower} + */ + get follower() { + return this.env.models['mail.follower'].get(this.props.followerLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDetails(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this.follower.openProfile(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEdit(ev) { + ev.preventDefault(); + this.follower.showSubtypes(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRemove(ev) { + this.follower.remove(); + } + +} + +Object.assign(Follower, { + components, + props: { + followerLocalId: String, + }, + template: 'mail.Follower', +}); + +return Follower; + +}); diff --git a/addons/mail/static/src/components/follower/follower.scss b/addons/mail/static/src/components/follower/follower.scss new file mode 100644 index 00000000..509a119f --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.scss @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Follower { + display: flex; + flex-flow: row; + justify-content: space-between; + padding: map-get($spacers, 0); +} + +.o_Follower_avatar { + width: 24px; + height: 24px; + margin-inline-end: map-get($spacers, 2); +} + +.o_Follower_details { + align-items: center; + display: flex; + flex: 1; + padding-left: map-get($spacers, 3); + padding-right: map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Follower_avatar { + border-radius: 50%; +} + +.o_Follower_button { + border-radius: 0; + + &:hover { + background: gray('400'); + color: $black; + } +} + +.o_Follower_details { + color: gray('700'); + + &:hover { + background: gray('400'); + color: $black; + } + + &.o-inactive { + opacity: 0.25; + font-style: italic; + } +} diff --git a/addons/mail/static/src/components/follower/follower.xml b/addons/mail/static/src/components/follower/follower.xml new file mode 100644 index 00000000..5cdc89d7 --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Follower" owl="1"> + <div class="o_Follower"> + <t t-if="follower"> + <a class="o_Follower_details" t-att-class="{ 'o-inactive': !follower.isActive }" href="#" t-on-click="_onClickDetails"> + <img class="o_Follower_avatar" t-attf-src="/web/image/{{ follower.resModel }}/{{ follower.resId }}/image_128" alt="Avatar"/> + <span class="o_Follower_name" t-esc="follower.name or follower.displayName"/> + </a> + <t t-if="follower.isEditable"> + <button class="btn btn-icon o_Follower_button o_Follower_editButton" title="Edit subscription" t-on-click="_onClickEdit"> + <i class="fa fa-pencil"/> + </button> + <button class="btn btn-icon o_Follower_button o_Follower_removeButton" title="Remove this follower" t-on-click="_onClickRemove"> + <i class="fa fa-remove"/> + </button> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower/follower_tests.js b/addons/mail/static/src/components/follower/follower_tests.js new file mode 100644 index 00000000..28058fc9 --- /dev/null +++ b/addons/mail/static/src/components/follower/follower_tests.js @@ -0,0 +1,380 @@ +odoo.define('mail/static/src/components/follower/follower_tests.js', function (require) { +'use strict'; + +const components = { + Follower: require('mail/static/src/components/follower/follower.js'), +}; +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower', {}, function () { +QUnit.module('follower_tests.js', { + beforeEach() { + beforeEach(this); + + this.createFollowerComponent = async (follower) => { + await createRootComponent(this, components.Follower, { + props: { followerLocalId: follower.localId }, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: false, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + assert.containsOnce( + document.body, + '.o_Follower_avatar', + "should display the avatar of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_name', + "should display the name of the follower" + ); + assert.containsNone( + document.body, + '.o_Follower_button', + "should have no button as follower is not editable" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(6); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + assert.containsOnce( + document.body, + '.o_Follower_avatar', + "should display the avatar of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_name', + "should display the name of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_editButton', + "should have an edit button" + ); + assert.containsOnce( + document.body, + '.o_Follower_removeButton', + "should have a remove button" + ); +}); + +QUnit.test('click on channel follower details', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.res_id, + 10, + "The redirect action should redirect to the right res id (10)" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.channel', + "The redirect action should redirect to the right res model (mail.channel)" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The redirect action should be of type 'ir.actions.act_window'" + ); + }); + this.data['res.partner'].records.push({ id: 100 }); + this.data['mail.channel'].records.push({ id: 10 }); + await this.start({ + env: { bus }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 10, model: 'mail.channel', name: "channel" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + + document.querySelector('.o_Follower_details').click(); + assert.verifySteps( + ['do_action'], + "clicking on channel should redirect to channel form view" + ); +}); + +QUnit.test('click on partner follower details', async function (assert) { + assert.expect(7); + + const openFormDef = makeDeferred(); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.res_id, + 3, + "The redirect action should redirect to the right res id (3)" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "The redirect action should redirect to the right res model (res.partner)" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The redirect action should be of type 'ir.actions.act_window'" + ); + openFormDef.resolve(); + }); + this.data['res.partner'].records.push({ id: 100 }); + await this.start({ + env: { bus }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.messaging.currentPartner.id, + name: "François Perusse", + }]], + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + + document.querySelector('.o_Follower_details').click(); + await openFormDef; + assert.verifySteps( + ['do_action'], + "clicking on follower should redirect to partner form view" + ); +}); + +QUnit.test('click on edit follower', async function (assert) { + assert.expect(5); + + this.data['res.partner'].records.push({ id: 100, message_follower_ids: [2] }); + this.data['mail.followers'].records.push({ + id: 2, + is_active: true, + is_editable: true, + partner_id: this.data.currentPartnerId, + res_id: 100, + res_model: 'res.partner', + }); + await this.start({ + hasDialog: true, + async mockRPC(route, args) { + if (route.includes('/mail/read_subscription_data')) { + assert.step('fetch_subtypes'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await thread.refreshFollowers(); + await this.createFollowerComponent(thread.followers[0]); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_editButton', + "should display an edit button" + ); + + await afterNextRender(() => document.querySelector('.o_Follower_editButton').click()); + assert.verifySteps( + ['fetch_subtypes'], + "clicking on edit follower should fetch subtypes" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtypeList', + "A dialog allowing to edit follower subtypes should have been created" + ); +}); + +QUnit.test('edit follower and close subtype dialog', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push({ id: 100 }); + await this.start({ + hasDialog: true, + async mockRPC(route, args) { + if (route.includes('/mail/read_subscription_data')) { + assert.step('fetch_subtypes'); + return [{ + default: true, + followed: true, + internal: false, + id: 1, + name: "Dummy test", + res_model: 'res.partner' + }]; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.messaging.currentPartner.id, + name: "François Perusse", + }]], + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_editButton', + "should display an edit button" + ); + + await afterNextRender(() => document.querySelector('.o_Follower_editButton').click()); + assert.verifySteps( + ['fetch_subtypes'], + "clicking on edit follower should fetch subtypes" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtypeList', + "dialog allowing to edit follower subtypes should have been created" + ); + + await afterNextRender( + () => document.querySelector('.o_FollowerSubtypeList_closeButton').click() + ); + assert.containsNone( + document.body, + '.o_DialogManager_dialog', + "follower subtype dialog should be closed after clicking on close button" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js new file mode 100644 index 00000000..996ef1f5 --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js @@ -0,0 +1,154 @@ +odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu.js', function (require) { +'use strict'; + +const components = { + Follower: require('mail/static/src/components/follower/follower.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef, useState } = owl.hooks; + +class FollowerListMenu extends Component { + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + this.state = useState({ + /** + * Determine whether the dropdown is open or not. + */ + isDropdownOpen: false, + }); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const followers = thread ? thread.followers : []; + return { + followers, + threadChannelType: thread && thread.channel_type, + }; + }, { + compareDepth: { + followers: 1, + }, + }); + this._dropdownRef = useRef('dropdown'); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @return {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _hide() { + this.state.isDropdownOpen = false; + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + ev.stopPropagation(); + switch (ev.key) { + case 'Escape': + ev.preventDefault(); + this._hide(); + break; + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAddChannels(ev) { + ev.preventDefault(); + this._hide(); + this.thread.promptAddChannelFollower(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAddFollowers(ev) { + ev.preventDefault(); + this._hide(); + this.thread.promptAddPartnerFollower(); + } + + /** + * Close the dropdown when clicking outside of it. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + // since dropdown is conditionally shown based on state, dropdownRef can be null + if (this._dropdownRef.el && !this._dropdownRef.el.contains(ev.target)) { + this._hide(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollowersButton(ev) { + this.state.isDropdownOpen = !this.state.isDropdownOpen; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollower(ev) { + this._hide(); + } +} + +Object.assign(FollowerListMenu, { + components, + defaultProps: { + isDisabled: false, + }, + props: { + isDisabled: Boolean, + threadLocalId: String, + }, + template: 'mail.FollowerListMenu', +}); + +return FollowerListMenu; + +}); diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss new file mode 100644 index 00000000..6e82134a --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerListMenu { + position: relative; +} + +.o_FollowerListMenu_dropdown { + display: flex; + flex-flow: column; + overflow-y: auto; +} + +.o_FollowerListMenu_followers { + display: flex; +} diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml new file mode 100644 index 00000000..86b7f3a6 --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerListMenu" owl="1"> + <div class="o_FollowerListMenu" t-on-keydown="_onKeydown"> + <div class="o_FollowerListMenu_followers" t-ref="dropdown"> + <button class="o_FollowerListMenu_buttonFollowers btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollowersButton" title="Show Followers"> + <i class="fa fa-user"/> + <span class="o_FollowerListMenu_buttonFollowersCount pl-1" t-esc="thread.followers.length"/> + </button> + + <t t-if="state.isDropdownOpen"> + <div class="o_FollowerListMenu_dropdown dropdown-menu dropdown-menu-right" role="menu"> + <t t-if="thread.channel_type !== 'chat'"> + <a class="o_FollowerListMenu_addFollowersButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddFollowers"> + Add Followers + </a> + </t> + <a class="o_FollowerListMenu_addChannelsButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddChannels"> + Add Channels + </a> + <t t-if="thread.followers.length > 0"> + <div role="separator" class="dropdown-divider"/> + <t t-foreach="thread.followers" t-as="follower" t-key="follower.localId"> + <Follower + class="o_FollowerMenu_follower dropdown-item" + followerLocalId="follower.localId" + t-on-click="_onClickFollower" + /> + </t> + </t> + </div> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js new file mode 100644 index 00000000..cf6fcf24 --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js @@ -0,0 +1,424 @@ +odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu_tests.js', function (require) { +'use strict'; + +const components = { + FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower_list_menu', {}, function () { +QUnit.module('follower_list_menu_tests.js', { + beforeEach() { + beforeEach(this); + + this.createFollowerListMenuComponent = async (thread, otherProps = {}) => { + const props = Object.assign({ threadLocalId: thread.localId }, otherProps); + await createRootComponent(this, components.FollowerListMenu, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread, { isDisabled: true }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.ok( + document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled, + "followers button should be disabled" + ); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should not be opened" + ); + + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should still be closed as button is disabled" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.notOk( + document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled, + "followers button should not be disabled" + ); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should not be opened" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); +}); + +QUnit.test('click on "add followers" button', async function (assert) { + assert.expect(16); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('action:open_view'); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + "'The 'add followers' action should contain thread model in context'" + ); + assert.notOk( + payload.action.context.mail_invite_follower_channel_only, + "The 'add followers' action should not be restricted to channels only" + ); + assert.strictEqual( + payload.action.context.default_res_id, + 100, + "The 'add followers' action should contain thread id in context" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.wizard.invite', + "The 'add followers' action should be a wizard invite of mail module" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The 'add followers' action should be of type 'ir.actions.act_window'" + ); + const partner = this.data['res.partner'].records.find( + partner => partner.id === payload.action.context.default_res_id + ); + partner.message_follower_ids.push(1); + payload.options.on_close(); + }); + this.data['res.partner'].records.push({ id: 100 }); + this.data['mail.followers'].records.push({ + partner_id: 42, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + res_id: 100, + res_model: 'res.partner', + }); + await this.start({ + env: { bus }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "0", + "Followers counter should be equal to 0" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_addFollowersButton', + "followers dropdown should contain a 'Add followers' button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_addFollowersButton').click(); + }); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be closed after click on 'Add followers'" + ); + assert.verifySteps([ + 'action:open_view', + ]); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "1", + "Followers counter should now be equal to 1" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerMenu_follower', + "Follower list should be refreshed and contain a follower" + ); + assert.strictEqual( + document.querySelector('.o_Follower_name').textContent, + "François Perusse", + "Follower added in follower list should be the one added" + ); +}); + +QUnit.test('click on "add channels" button', async function (assert) { + assert.expect(16); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('action:open_view'); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + "'The 'add channels' action should contain thread model in context'" + ); + assert.ok( + payload.action.context.mail_invite_follower_channel_only, + "The 'add channels' action should be restricted to channels only" + ); + assert.strictEqual( + payload.action.context.default_res_id, + 100, + "The 'add channels' action should contain thread id in context" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.wizard.invite', + "The 'add channels' action should be a wizard invite of mail module" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The 'add channels' action should be of type 'ir.actions.act_window'" + ); + const partner = this.data['res.partner'].records.find( + partner => partner.id === payload.action.context.default_res_id + ); + partner.message_follower_ids.push(1); + payload.options.on_close(); + }); + this.data['res.partner'].records.push({ id: 100 }); + this.data['mail.followers'].records.push({ + channel_id: 42, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "Supa channel", + res_id: 100, + res_model: 'res.partner', + }); + await this.start({ + env: { bus }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "0", + "Followers counter should be equal to 0" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_addChannelsButton', + "followers dropdown should contain a 'Add channels' button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_addChannelsButton').click(); + }); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be closed after click on 'add channels'" + ); + assert.verifySteps([ + 'action:open_view', + ]); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "1", + "Followers counter should now be equal to 1" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerMenu_follower', + "Follower list should be refreshed and contain a follower" + ); + assert.strictEqual( + document.querySelector('.o_Follower_name').textContent, + "Supa channel", + "Follower added in follower list should be the one added" + ); +}); + +QUnit.test('click on remove follower', async function (assert) { + assert.expect(6); + + const self = this; + await this.start({ + async mockRPC(route, args) { + if (route.includes('message_unsubscribe')) { + assert.step('message_unsubscribe'); + assert.deepEqual( + args.args, + [[100], [self.env.messaging.currentPartner.id], []], + "message_unsubscribe should be called with right argument" + ); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.messaging.currentPartner.id, + name: "François Perusse", + }]], + }); + await this.createFollowerListMenuComponent(thread); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_removeButton', + "should display a remove button" + ); + + await afterNextRender(() => { + document.querySelector('.o_Follower_removeButton').click(); + }); + assert.verifySteps( + ['message_unsubscribe'], + "clicking on remove button should call 'message_unsubscribe' route" + ); + assert.containsNone( + document.body, + '.o_Follower', + "should no longer have follower component" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.js b/addons/mail/static/src/components/follower_subtype/follower_subtype.js new file mode 100644 index 00000000..ae3ba321 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.js @@ -0,0 +1,71 @@ +odoo.define('mail/static/src/components/follower_subtype/follower_subtype.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class FollowerSubtype extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const followerSubtype = this.env.models['mail.follower_subtype'].get(props.followerSubtypeLocalId); + return [followerSubtype ? followerSubtype.__state : undefined]; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower|undefined} + */ + get follower() { + return this.env.models['mail.follower'].get(this.props.followerLocalId); + } + + /** + * @returns {mail.follower_subtype} + */ + get followerSubtype() { + return this.env.models['mail.follower_subtype'].get(this.props.followerSubtypeLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on cancel button. + * + * @private + * @param {Event} ev + */ + _onChangeCheckbox(ev) { + if (ev.target.checked) { + this.follower.selectSubtype(this.followerSubtype); + } else { + this.follower.unselectSubtype(this.followerSubtype); + } + } + +} + +Object.assign(FollowerSubtype, { + props: { + followerLocalId: String, + followerSubtypeLocalId: String, + }, + template: 'mail.FollowerSubtype', +}); + +return FollowerSubtype; + +}); diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.scss b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss new file mode 100644 index 00000000..3be0ad46 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerSubtype_checkbox { + margin-inline-end: map-get($spacers, 2); +} + +.o_FollowerSubtype_label { + display: flex; + flex: 1; + flex-direction: row; + align-items: center; + margin-bottom: map-get($spacers, 0); + padding: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_FollowerSubtype_label { + cursor: pointer; + &:hover { + background-color: gray('200'); + } +} diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.xml b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml new file mode 100644 index 00000000..b2380009 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerSubtype" owl="1"> + <div class="o_FollowerSubtype"> + <label class="o_FollowerSubtype_label"> + <input class="o_FollowerSubtype_checkbox" type="checkbox" t-att-checked="follower.selectedSubtypes.includes(followerSubtype) ? 'checked': ''" t-on-change="_onChangeCheckbox"/> + <t t-esc="followerSubtype.name"/> + </label> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js new file mode 100644 index 00000000..7c802a7a --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js @@ -0,0 +1,233 @@ +odoo.define('mail/static/src/components/follower_subtype/follower_subtype_tests.js', function (require) { +'use strict'; + +const components = { + FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower_subtype', {}, function () { +QUnit.module('follower_subtype_tests.js', { + beforeEach() { + beforeEach(this); + + this.createFollowerSubtypeComponent = async ({ follower, followerSubtype }) => { + const props = { + followerLocalId: follower.localId, + followerSubtypeLocalId: followerSubtype.localId, + }; + await createRootComponent(this, components.FollowerSubtype, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('simplest layout of a followed subtype', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ + selectedSubtypes: [['link', followerSubtype]], + subtypes: [['link', followerSubtype]], + }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_label', + "should have a label" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.strictEqual( + document.querySelector('.o_FollowerSubtype_label').textContent, + "Dummy test", + "should have the name of the subtype as label" + ); + assert.ok( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should be checked as follower subtype is followed" + ); +}); + +QUnit.test('simplest layout of a not followed subtype', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ subtypes: [['link', followerSubtype]] }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_label', + "should have a label" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.strictEqual( + document.querySelector('.o_FollowerSubtype_label').textContent, + "Dummy test", + "should have the name of the subtype as label" + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should not be checked as follower subtype is not followed" + ); +}); + +QUnit.test('toggle follower subtype checkbox', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ subtypes: [['link', followerSubtype]] }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should not be checked as follower subtype is not followed" + ); + + await afterNextRender(() => + document.querySelector('.o_FollowerSubtype_checkbox').click() + ); + assert.ok( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should now be checked" + ); + + await afterNextRender(() => + document.querySelector('.o_FollowerSubtype_checkbox').click() + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should be no more checked" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js new file mode 100644 index 00000000..d5cca5b5 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/src/components/follower_subtype_list/follower_subtype_list.js', function (require) { +'use strict'; + +const components = { + FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component, QWeb } = owl; + +class FollowerSubtypeList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const followerSubtypeList = this.env.models['mail.follower_subtype_list'].get(props.localId); + const follower = followerSubtypeList + ? followerSubtypeList.follower + : undefined; + const followerSubtypes = follower ? follower.subtypes : []; + return { + follower: follower ? follower.__state : undefined, + followerSubtypeList: followerSubtypeList + ? followerSubtypeList.__state + : undefined, + followerSubtypes: followerSubtypes.map(subtype => subtype.__state), + }; + }, { + compareDepth: { + followerSubtypes: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower_subtype_list} + */ + get followerSubtypeList() { + return this.env.models['mail.follower_subtype_list'].get(this.props.localId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on cancel button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCancel(ev) { + this.followerSubtypeList.follower.closeSubtypes(); + } + + /** + * Called when clicking on apply button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickApply(ev) { + this.followerSubtypeList.follower.updateSubtypes(); + } + +} + +Object.assign(FollowerSubtypeList, { + components, + props: { + localId: String, + }, + template: 'mail.FollowerSubtypeList', +}); + +QWeb.registerComponent('FollowerSubtypeList', FollowerSubtypeList); + +return FollowerSubtypeList; + +}); diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss new file mode 100644 index 00000000..82aef9fc --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss @@ -0,0 +1,8 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerSubtypeList_subtypes { + display: flex; + flex-flow: column; +} diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml new file mode 100644 index 00000000..ad477d9d --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerSubtypeList" owl="1"> + <div class="o_FollowerSubtypeList modal-dialog"> + <t t-if="followerSubtypeList"> + <div class="modal-content"> + <header class="modal-header"> + <h4 class="modal-title"> + Edit Subscription of <t t-esc="followerSubtypeList.follower.name"/> + </h4> + <i class="o_FollowerSubtypeList_closeButton close fa fa-times" aria-label="Close" t-on-click="_onClickCancel"/> + </header> + <main class="modal-body"> + <div class="o_FollowerSubtypeList_subtypes"> + <t t-foreach="followerSubtypeList.follower.subtypes" t-as="subtype" t-key="subtype.id"> + <FollowerSubtype + class="o_FollowerSubtypeList_subtype" + followerLocalId="followerSubtypeList.follower.localId" + followerSubtypeLocalId="subtype.localId" + /> + </t> + </div> + </main> + <div class="modal-footer"> + <button class="o-apply btn btn-primary" t-on-click="_onClickApply"> + Apply + </button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel"> + Cancel + </button> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/mail_template/mail_template.js b/addons/mail/static/src/components/mail_template/mail_template.js new file mode 100644 index 00000000..32c334be --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.js @@ -0,0 +1,81 @@ +odoo.define('mail/static/src/components/mail_template/mail_template.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MailTemplate extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + const mailTemplate = this.env.models['mail.mail_template'].get(props.mailTemplateLocalId); + return { + activity: activity ? activity.__state : undefined, + mailTemplate: mailTemplate ? mailTemplate.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {mail.mail_template} + */ + get mailTemplate() { + return this.env.models['mail.mail_template'].get(this.props.mailTemplateLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickPreview(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.mailTemplate.preview(this.activity); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSend(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.mailTemplate.send(this.activity); + } + +} + +Object.assign(MailTemplate, { + props: { + activityLocalId: String, + mailTemplateLocalId: String, + }, + template: 'mail.MailTemplate', +}); + +return MailTemplate; + +}); diff --git a/addons/mail/static/src/components/mail_template/mail_template.scss b/addons/mail/static/src/components/mail_template/mail_template.scss new file mode 100644 index 00000000..7800ab62 --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.scss @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MailTemplate { + display: flex; + flex: 0 0 auto; + align-items: center; +} + +.o_MailTemplate_button { + padding-top: map-get($spacers, 0); + padding-bottom: map-get($spacers, 0); +} + +.o_MailTemplate_name { + margin-inline-start: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MailTemplate_text { + color: gray('500'); + font-style: italic; +} diff --git a/addons/mail/static/src/components/mail_template/mail_template.xml b/addons/mail/static/src/components/mail_template/mail_template.xml new file mode 100644 index 00000000..48f7c050 --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MailTemplate" owl="1"> + <div class="o_MailTemplate"> + <t t-if="mailTemplate"> + <i class="fa fa-envelope-o" title="Mail" role="img"/> + <span class="o_MailTemplate_name" t-esc="mailTemplate.name"/> + <span>:</span> + <button + class="o_MailTemplate_button o_MailTemplate_preview btn btn-link" + t-att-data-mail-template-id="mailTemplate.id" + t-on-click="_onClickPreview" + > + Preview + </button> + <span class="o_MailTemplate_text">or</span> + <button + class="o_MailTemplate_button o_MailTemplate_send btn btn-link" + t-att-data-mail-template-id="mailTemplate.id" + t-on-click="_onClickSend" + > + Send Now + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js new file mode 100644 index 00000000..a357c024 --- /dev/null +++ b/addons/mail/static/src/components/message/message.js @@ -0,0 +1,680 @@ +odoo.define('mail/static/src/components/message/message.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + MessageSeenIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'), + ModerationBanDialog: require('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js'), + ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'), + ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'), + NotificationPopover: require('mail/static/src/components/notification_popover/notification_popover.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const { _lt } = require('web.core'); +const { format } = require('web.field_utils'); +const { getLangDatetimeFormat } = require('web.time'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +const READ_MORE = _lt("read more"); +const READ_LESS = _lt("read less"); +const { isEventHandled, markEventHandled } = require('mail/static/src/utils/utils.js'); + +class Message extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + // Determine if the moderation ban dialog is displayed. + hasModerationBanDialog: false, + // Determine if the moderation discard dialog is displayed. + hasModerationDiscardDialog: false, + // Determine if the moderation reject dialog is displayed. + hasModerationRejectDialog: false, + /** + * Determine whether the message is clicked. When message is in + * clicked state, it keeps displaying the commands. + */ + isClicked: false, + }); + useShouldUpdateBasedOnProps(); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const author = message ? message.author : undefined; + const partnerRoot = this.env.messaging.partnerRoot; + const originThread = message ? message.originThread : undefined; + const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId); + const thread = threadView ? threadView.thread : undefined; + return { + attachments: message + ? message.attachments.map(attachment => attachment.__state) + : [], + author, + authorAvatarUrl: author && author.avatarUrl, + authorImStatus: author && author.im_status, + authorNameOrDisplayName: author && author.nameOrDisplayName, + correspondent: thread && thread.correspondent, + hasMessageCheckbox: message ? message.hasCheckbox : false, + isDeviceMobile: this.env.messaging.device.isMobile, + isMessageChecked: message && threadView + ? message.isChecked(thread, threadView.stringifiedDomain) + : false, + message: message ? message.__state : undefined, + notifications: message ? message.notifications.map(notif => notif.__state) : [], + originThread, + originThreadModel: originThread && originThread.model, + originThreadName: originThread && originThread.name, + originThreadUrl: originThread && originThread.url, + partnerRoot, + thread, + threadHasSeenIndicators: thread && thread.hasSeenIndicators, + threadMassMailing: thread && thread.mass_mailing, + }; + }, { + compareDepth: { + attachments: 1, + notifications: 1, + }, + }); + useUpdate({ func: () => this._update() }); + /** + * The intent of the reply button depends on the last rendered state. + */ + this._wasSelected; + /** + * Value of the last rendered prettyBody. Useful to compare to new value + * to decide if it has to be updated. + */ + this._lastPrettyBody; + /** + * Reference to element containing the prettyBody. Useful to be able to + * replace prettyBody with new value in JS (which is faster than t-raw). + */ + this._prettyBodyRef = useRef('prettyBody'); + /** + * Reference to the content of the message. + */ + this._contentRef = useRef('content'); + /** + * To get checkbox state. + */ + this._checkboxRef = useRef('checkbox'); + /** + * Id of setInterval used to auto-update time elapsed of message at + * regular time. + */ + this._intervalId = undefined; + this._constructor(); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + willUnmount() { + clearInterval(this._intervalId); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get avatar() { + if ( + this.message.author && + this.message.author === this.env.messaging.partnerRoot + ) { + return '/mail/static/src/img/odoobot.png'; + } else if (this.message.author) { + // TODO FIXME for public user this might not be accessible. task-2223236 + // we should probably use the correspondig attachment id + access token + // or create a dedicated route to get message image, checking the access right of the message + return this.message.author.avatarUrl; + } else if (this.message.message_type === 'email') { + return '/mail/static/src/img/email_icon.png'; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + } + + /** + * Get the date time of the message at current user locale time. + * + * @returns {string} + */ + get datetime() { + return this.message.date.format(getLangDatetimeFormat()); + } + + /** + * Determines whether author open chat feature is enabled on message. + * + * @returns {boolean} + */ + get hasAuthorOpenChat() { + if (!this.message.author) { + return false; + } + if ( + this.threadView && + this.threadView.thread && + this.threadView.thread.correspondent === this.message.author + ) { + return false; + } + return true; + } + + /** + * Tell whether the bottom of this message is visible or not. + * + * @param {Object} param0 + * @param {integer} [offset=0] + * @returns {boolean} + */ + isBottomVisible({ offset=0 } = {}) { + if (!this.el) { + return false; + } + const elRect = this.el.getBoundingClientRect(); + if (!this.el.parentNode) { + return false; + } + const parentRect = this.el.parentNode.getBoundingClientRect(); + // bottom with (double) 10px offset + return ( + elRect.bottom < parentRect.bottom + offset && + parentRect.top < elRect.bottom + offset + ); + } + + /** + * Tell whether the message is partially visible on browser window or not. + * + * @returns {boolean} + */ + isPartiallyVisible() { + const elRect = this.el.getBoundingClientRect(); + if (!this.el.parentNode) { + return false; + } + const parentRect = this.el.parentNode.getBoundingClientRect(); + // intersection with 5px offset + return ( + elRect.top < parentRect.bottom + 5 && + parentRect.top < elRect.bottom + 5 + ); + } + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + /** + * @returns {string} + */ + get OPEN_CHAT() { + return this.env._t("Open chat"); + } + + /** + * Make this message viewable in its enclosing scroll environment (usually + * message list). + * + * @param {Object} [param0={}] + * @param {string} [param0.behavior='auto'] + * @param {string} [param0.block='end'] + * @returns {Promise} + */ + async scrollIntoView({ behavior = 'auto', block = 'end' } = {}) { + this.el.scrollIntoView({ + behavior, + block, + inline: 'nearest', + }); + if (behavior === 'smooth') { + return new Promise(resolve => setTimeout(resolve, 500)); + } else { + return Promise.resolve(); + } + } + + /** + * Get the shorttime format of the message date. + * + * @returns {string} + */ + get shortTime() { + return this.message.date.format('hh:mm'); + } + + /** + * @returns {mail.thread_view} + */ + get threadView() { + return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId); + } + + /** + * @returns {Object} + */ + get trackingValues() { + return this.message.tracking_value_ids.map(trackingValue => { + const value = Object.assign({}, trackingValue); + value.changed_field = _.str.sprintf(this.env._t("%s:"), value.changed_field); + /** + * Maps tracked field type to a JS formatter. Tracking values are + * not always stored in the same field type as their origin type. + * Field types that are not listed here are not supported by + * tracking in Python. Also see `create_tracking_values` in Python. + */ + switch (value.field_type) { + case 'boolean': + value.old_value = format.boolean(value.old_value, undefined, { forceString: true }); + value.new_value = format.boolean(value.new_value, undefined, { forceString: true }); + break; + /** + * many2one formatter exists but is expecting id/name_get or data + * object but only the target record name is known in this context. + * + * Selection formatter exists but requires knowing all + * possibilities and they are not given in this context. + */ + case 'char': + case 'many2one': + case 'selection': + value.old_value = format.char(value.old_value); + value.new_value = format.char(value.new_value); + break; + case 'date': + if (value.old_value) { + value.old_value = moment.utc(value.old_value); + } + if (value.new_value) { + value.new_value = moment.utc(value.new_value); + } + value.old_value = format.date(value.old_value); + value.new_value = format.date(value.new_value); + break; + case 'datetime': + if (value.old_value) { + value.old_value = moment.utc(value.old_value); + } + if (value.new_value) { + value.new_value = moment.utc(value.new_value); + } + value.old_value = format.datetime(value.old_value); + value.new_value = format.datetime(value.new_value); + break; + case 'float': + value.old_value = format.float(value.old_value); + value.new_value = format.float(value.new_value); + break; + case 'integer': + value.old_value = format.integer(value.old_value); + value.new_value = format.integer(value.new_value); + break; + case 'monetary': + value.old_value = format.monetary(value.old_value, undefined, { forceString: true }); + value.new_value = format.monetary(value.new_value, undefined, { forceString: true }); + break; + case 'text': + value.old_value = format.text(value.old_value); + value.new_value = format.text(value.new_value); + break; + } + return value; + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Modifies the message to add the 'read more/read less' functionality + * All element nodes with 'data-o-mail-quote' attribute are concerned. + * All text nodes after a ``#stopSpelling`` element are concerned. + * Those text nodes need to be wrapped in a span (toggle functionality). + * All consecutive elements are joined in one 'read more/read less'. + * + * FIXME This method should be rewritten (task-2308951) + * + * @private + * @param {jQuery} $element + */ + _insertReadMoreLess($element) { + const groups = []; + let readMoreNodes; + + // nodeType 1: element_node + // nodeType 3: text_node + const $children = $element.contents() + .filter((index, content) => + content.nodeType === 1 || (content.nodeType === 3 && content.nodeValue.trim()) + ); + + for (const child of $children) { + let $child = $(child); + + // Hide Text nodes if "stopSpelling" + if ( + child.nodeType === 3 && + $child.prevAll('[id*="stopSpelling"]').length > 0 + ) { + // Convert Text nodes to Element nodes + $child = $('<span>', { + text: child.textContent, + 'data-o-mail-quote': '1', + }); + child.parentNode.replaceChild($child[0], child); + } + + // Create array for each 'read more' with nodes to toggle + if ( + $child.attr('data-o-mail-quote') || + ( + $child.get(0).nodeName === 'BR' && + $child.prev('[data-o-mail-quote="1"]').length > 0 + ) + ) { + if (!readMoreNodes) { + readMoreNodes = []; + groups.push(readMoreNodes); + } + $child.hide(); + readMoreNodes.push($child); + } else { + readMoreNodes = undefined; + this._insertReadMoreLess($child); + } + } + + for (const group of groups) { + // Insert link just before the first node + const $readMoreLess = $('<a>', { + class: 'o_Message_readMoreLess', + href: '#', + text: READ_MORE, + }).insertBefore(group[0]); + + // Toggle All next nodes + let isReadMore = true; + $readMoreLess.click(e => { + e.preventDefault(); + isReadMore = !isReadMore; + for (const $child of group) { + $child.hide(); + $child.toggle(!isReadMore); + } + $readMoreLess.text(isReadMore ? READ_MORE : READ_LESS); + }); + } + } + + /** + * @private + */ + _update() { + if (!this.message) { + return; + } + if (this._prettyBodyRef.el && this.message.prettyBody !== this._lastPrettyBody) { + this._prettyBodyRef.el.innerHTML = this.message.prettyBody; + this._lastPrettyBody = this.message.prettyBody; + } + // Remove all readmore before if any before reinsert them with _insertReadMoreLess. + // This is needed because _insertReadMoreLess is working with direct DOM mutations + // which are not sync with Owl. + if (this._contentRef.el) { + for (const el of [...this._contentRef.el.querySelectorAll(':scope .o_Message_readMoreLess')]) { + el.remove(); + } + this._insertReadMoreLess($(this._contentRef.el)); + this.env.messagingBus.trigger('o-component-message-read-more-less-inserted', { + message: this.message, + }); + } + this._wasSelected = this.props.isSelected; + this.message.refreshDateFromNow(); + clearInterval(this._intervalId); + this._intervalId = setInterval(() => { + this.message.refreshDateFromNow(); + }, 60 * 1000); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onChangeCheckbox() { + this.message.toggleCheck(this.threadView.thread, this.threadView.stringifiedDomain); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (ev.target.closest('.o_channel_redirect')) { + this.env.messaging.openProfile({ + id: Number(ev.target.dataset.oeId), + model: 'mail.channel', + }); + // avoid following dummy href + ev.preventDefault(); + return; + } + if (ev.target.tagName === 'A') { + if (ev.target.dataset.oeId && ev.target.dataset.oeModel) { + this.env.messaging.openProfile({ + id: Number(ev.target.dataset.oeId), + model: ev.target.dataset.oeModel, + }); + // avoid following dummy href + ev.preventDefault(); + } + return; + } + if ( + !isEventHandled(ev, 'Message.ClickAuthorAvatar') && + !isEventHandled(ev, 'Message.ClickAuthorName') && + !isEventHandled(ev, 'Message.ClickFailure') + ) { + this.state.isClicked = !this.state.isClicked; + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAuthorAvatar(ev) { + markEventHandled(ev, 'Message.ClickAuthorAvatar'); + if (!this.hasAuthorOpenChat) { + return; + } + this.message.author.openChat(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAuthorName(ev) { + markEventHandled(ev, 'Message.ClickAuthorName'); + if (!this.message.author) { + return; + } + this.message.author.openProfile(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFailure(ev) { + markEventHandled(ev, 'Message.ClickFailure'); + this.message.openResendAction(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationAccept(ev) { + ev.preventDefault(); + this.message.moderate('accept'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationAllow(ev) { + ev.preventDefault(); + this.message.moderate('allow'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationBan(ev) { + ev.preventDefault(); + this.state.hasModerationBanDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationDiscard(ev) { + ev.preventDefault(); + this.state.hasModerationDiscardDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationReject(ev) { + ev.preventDefault(); + this.state.hasModerationRejectDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickOriginThread(ev) { + // avoid following dummy href + ev.preventDefault(); + this.message.originThread.open(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickStar(ev) { + ev.stopPropagation(); + this.message.toggleStar(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + ev.stopPropagation(); + this.message.markAsRead(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickReply(ev) { + // Use this._wasSelected because this.props.isSelected might be changed + // by a global capture click handler (for example the one from Composer) + // before the current handler is executed. Indeed because it does a + // toggle it needs to take into account the value before the click. + if (this._wasSelected) { + this.env.messaging.discuss.clearReplyingToMessage(); + } else { + this.message.replyTo(); + } + } + + /** + * @private + */ + _onDialogClosedModerationBan() { + this.state.hasModerationBanDialog = false; + } + + /** + * @private + */ + _onDialogClosedModerationDiscard() { + this.state.hasModerationDiscardDialog = false; + } + + /** + * @private + */ + _onDialogClosedModerationReject() { + this.state.hasModerationRejectDialog = false; + } + +} + +Object.assign(Message, { + components, + defaultProps: { + hasCheckbox: false, + hasMarkAsReadIcon: false, + hasReplyIcon: false, + isSelected: false, + isSquashed: false, + }, + props: { + attachmentsDetailsMode: { + type: String, + optional: true, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + hasCheckbox: Boolean, + hasMarkAsReadIcon: Boolean, + hasReplyIcon: Boolean, + isSelected: Boolean, + isSquashed: Boolean, + messageLocalId: String, + threadViewLocalId: { + type: String, + optional: true, + }, + }, + template: 'mail.Message', +}); + +return Message; + +}); diff --git a/addons/mail/static/src/components/message/message.scss b/addons/mail/static/src/components/message/message.scss new file mode 100644 index 00000000..16d9c790 --- /dev/null +++ b/addons/mail/static/src/components/message/message.scss @@ -0,0 +1,381 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Message { + display: flex; + flex: 0 0 auto; + padding: map-get($spacers, 2); +} + +.o_Message_authorAvatar { + height: 100%; + width: 100%; + object-fit: cover; +} + +.o_Message_authorAvatarContainer { + position: relative; + height: 36px; + width: 36px; +} + +.o_Message_authorName { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_checkbox { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_commandStar { + font-size: 1.3em; +} + +.o_Message_Composer { + flex: 1 1 auto; +} + +.o_Message_commands { + display: flex; + align-items: center; +} + +.o_Message_content { + word-wrap: break-word; + word-break: break-word; + + *:not(li):not(li div) { + // Message content can contain arbitrary HTML that might overflow and break + // the style without this rule. + // Lists are ignored because otherwise bullet style become hidden from overflow. + // It's acceptable not to manage overflow of these tags for the moment. + // It also excludes all div in li because 1st leaf and div child of list overflow + // may impact the bullet point (at least it does on Safari). + max-width: 100%; + overflow-x: auto; + } + + img { + max-width: 100%; + height: auto; + } +} + +.o_Message_core { + min-width: 0; // allows this flex child to shrink more than its content + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_footer { + display: flex; + flex-direction: column; +} + +.o_Message_header { + display: flex; + flex-flow: row wrap; + align-items: baseline; +} + +.o_Message_headerCommands { + margin-inline-end: map-get($spacers, 2); + align-self: center; + + .o_Message_headerCommand { + padding-left: map-get($spacers, 2); + padding-right: map-get($spacers, 2); + + &.o-mobile { + padding-left: map-get($spacers, 3); + padding-right: map-get($spacers, 3); + + &:first-child { + padding-left: map-get($spacers, 2); + } + + &:last-child { + padding-right: map-get($spacers, 2); + } + } + } +} + +.o_Message_headerDate { + margin-inline-end: map-get($spacers, 2); + font-size: 0.8em; +} + +.o_Message_moderationAction { + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_moderationPending { + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_moderationSubHeader { + display: flex; + flex-flow: row wrap; + align-items: center; +} + +.o_Message_originThread { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_partnerImStatusIcon { + @include o-position-absolute($bottom: 0, $right: 0); + display: flex; + align-items: center; + justify-content: center; +} + +.o_Message_prettyBody { + + > p:last-of-type { + margin-bottom: 0; + } + +} + +.o_Message_readMoreLess { + display: block; +} + +.o_Message_seenIndicator { + margin-inline-end: map-get($spacers, 1); +} + +.o_Message_sidebar { + flex: 0 0 $o-mail-message-sidebar-width; + max-width: $o-mail-message-sidebar-width; + display: flex; + margin-inline-end: map-get($spacers, 2); + justify-content: center; + + &.o-message-squashed { + align-items: flex-start; + } +} + +.o_Message_sidebarItem { + margin-left: map-get($spacers, 1); + margin-right: map-get($spacers, 1); + + &.o-message-squashed { + display: flex; + } +} + +.o_Message_trackingValues { + margin-top: map-get($spacers, 2); +} + +.o_Message_trackingValue { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.o_Message_trackingValueItem { + margin-inline-end: map-get($spacers, 1); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Message { + background-color: white; + + &:hover, &.o-clicked { + + .o_Message_commands { + opacity: 1; + } + + .o_Message_sidebarItem.o-message-squashed { + display: flex; + } + + .o_Message_seenIndicator.o-message-squashed { + display: none; + } + } + + .o_Message_partnerImStatusIcon { + color: white; + } + + &.o-not-discussion { + background-color: lighten(gray('300'), 5%); + border-bottom: 1px solid darken(gray('300'), 5%); + + .o_Message_partnerImStatusIcon { + color: lighten(gray('300'), 5%); + } + + &.o-selected { + border-bottom: 1px solid darken(gray('400'), 5%); + } + } + + &.o-selected { + background-color: gray('400'); + + .o_Message_partnerImStatusIcon { + color: gray('400'); + } + } + + &.o-starred { + + .o_Message_commandStar { + display: flex; + } + + .o_Message_commands { + display: flex; + } + } +} + +.o_Message_authorName { + font-weight: bold; +} + +.o_Message_authorRedirect { + cursor: pointer; +} + +.o_Message_command { + cursor: pointer; + color: gray('400'); + + &:not(.o-mobile) { + &:hover { + filter: brightness(0.8); + } + } + + &.o-mobile { + filter: brightness(0.8); + + &:hover { + filter: brightness(0.75); + } + } + + &.o-message-selected { + color: gray('500'); + } +} + +.o_Message_commandStar { + + &.o-message-starred { + color: gold; + + &:hover { + filter: brightness(0.9); + } + } +} + +.o_Message_content .o_mention { + color: $o-brand-primary; + cursor: pointer; + + &:hover { + color: darken($o-brand-primary, 15%); + } +} + +.o_Message_date { + color: gray('500'); + + &.o-message-selected { + color: gray('600'); + } +} + +.o_Message_headerCommands:not(.o-mobile) { + opacity: 0; +} + +.o_Message_originThread { + font-size: 0.8em; + color: gray('500'); + + &.o-message-selected { + color: gray('600'); + } +} + +.o_Message_originThreadLink { + font-size: 1.25em; // original size +} + +.o_Message_partnerImStatusIcon:not(.o_Message_partnerImStatusIcon-mobile) { + font-size: x-small; +} + +.o_Message_moderationAction { + font-weight: bold; + font-style: italic; + + &.o-accept, + &.o-allow { + color: $o-mail-moderation-accept-color; + @include hover-focus { + color: darken($o-mail-moderation-accept-color, $emphasized-link-hover-darken-percentage); + } + } + + &.o-ban, + &.o-discard, + &.o-reject { + color: $o-mail-moderation-reject-color; + @include hover-focus { + color: darken($o-mail-moderation-reject-color, $emphasized-link-hover-darken-percentage); + } + } +} + +.o_Message_moderationPending { + font-style: italic; + + &.o-author { + color: theme-color('danger'); + font-weight: bold; + } +} + +.o_Message_notificationIconClickable { + color: gray('600'); + cursor: pointer; + + &.o-error { + color: $red; + } +} + +.o_Message_sidebarCommands { + display: none; +} + +.o_Message_sidebarItem.o-message-squashed { + display: none; +} + +.o_Message_subject { + font-style: italic; +} + +// Used to hide buttons on rating emails in chatter +// FIXME: should use a better approach for not having such buttons +// in chatter of such messages, but keep having them in emails. +.o_Message_content [summary~="o_mail_notification"] { + display: none; +} diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml new file mode 100644 index 00000000..32687ea6 --- /dev/null +++ b/addons/mail/static/src/components/message/message.xml @@ -0,0 +1,210 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Message" owl="1"> + <div class="o_Message" + t-att-class="{ + 'o-clicked': state.isClicked, + 'o-discussion': message and (message.is_discussion or message.is_notification), + 'o-mobile': env.messaging.device.isMobile, + 'o-not-discussion': message and !(message.is_discussion or message.is_notification), + 'o-notification': message and message.message_type === 'notification', + 'o-selected': props.isSelected, + 'o-squashed': props.isSquashed, + 'o-starred': message and message.isStarred, + }" t-on-click="_onClick" t-att-data-message-local-id="message and message.localId" + > + <t t-if="message" name="rootCondition"> + <div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed': props.isSquashed }"> + <t t-if="!props.isSquashed"> + <div class="o_Message_authorAvatarContainer o_Message_sidebarItem"> + <img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorOpenChat, o_redirect: hasAuthorOpenChat }" t-att-src="avatar" t-on-click="_onClickAuthorAvatar" t-att-title="hasAuthorOpenChat ? OPEN_CHAT : ''" alt="Avatar"/> + <t t-if="message.author and message.author.im_status"> + <PartnerImStatusIcon + class="o_Message_partnerImStatusIcon" + t-att-class="{ + 'o-message-not-discussion': !(message.is_discussion or message.is_notification), + 'o-message-selected': props.isSelected, + 'o_Message_partnerImStatusIcon-mobile': env.messaging.device.isMobile, + }" + hasOpenChat="hasAuthorOpenChat" + partnerLocalId="message.author.localId" + /> + </t> + </div> + </t> + <t t-else=""> + <div class="o_Message_date o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected }"> + <t t-esc="shortTime"/> + </div> + <div class="o_Message_commands o_Message_sidebarCommands o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected, 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="message.message_type !== 'notification'"> + <div class="o_Message_command o_Message_commandStar fa" + t-att-class="{ + 'fa-star': message.isStarred, + 'fa-star-o': !message.isStarred, + 'o-message-selected': props.isSelected, + 'o-message-starred': message.isStarred, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickStar" + /> + </t> + </div> + <t t-if="message.isCurrentPartnerAuthor and threadView and threadView.thread and threadView.thread.hasSeenIndicators"> + <MessageSeenIndicator class="o_Message_seenIndicator o-message-squashed" messageLocalId="message.localId" threadLocalId="threadView.thread.localId"/> + </t> + </t> + </div> + <div class="o_Message_core"> + <t t-if="!props.isSquashed"> + <div class="o_Message_header"> + <t t-if="message.author"> + <div class="o_Message_authorName o_Message_authorRedirect o_redirect" t-on-click="_onClickAuthorName" title="Open profile"> + <t t-esc="message.author.nameOrDisplayName"/> + </div> + </t> + <t t-elif="message.email_from"> + <a class="o_Message_authorName" t-attf-href="mailto:{{ message.email_from }}?subject=Re: {{ message.subject ? message.subject : '' }}"> + <t t-esc="message.email_from"/> + </a> + </t> + <t t-else=""> + <div class="o_Message_authorName"> + Anonymous + </div> + </t> + <div class="o_Message_date o_Message_headerDate" t-att-class="{ 'o-message-selected': props.isSelected }" t-att-title="datetime"> + - <t t-esc="message.dateFromNow"/> + </div> + <t t-if="message.isCurrentPartnerAuthor and threadView and threadView.thread and threadView.thread.hasSeenIndicators"> + <MessageSeenIndicator class="o_Message_seenIndicator" messageLocalId="message.localId" threadLocalId="threadView.thread.localId"/> + </t> + <t t-if="threadView and message.originThread and message.originThread !== threadView.thread"> + <div class="o_Message_originThread" t-att-class="{ 'o-message-selected': props.isSelected }"> + <t t-if="message.originThread.model === 'mail.channel'"> + (from <a class="o_Message_originThreadLink" t-att-href="message.originThread.url" t-on-click="_onClickOriginThread"><t t-if="message.originThread.name">#<t t-esc="message.originThread.name"/></t><t t-else="">channel</t></a>) + </t> + <t t-else=""> + on <a class="o_Message_originThreadLink" t-att-href="message.originThread.url" t-on-click="_onClickOriginThread"><t t-if="message.originThread.name"><t t-esc="message.originThread.name"/></t><t t-else="">document</t></a> + </t> + </div> + </t> + <t t-if="message.moderation_status === 'pending_moderation' and !message.isModeratedByCurrentPartner"> + <span class="o_Message_moderationPending o-author" title="Your message is pending moderation.">Pending moderation</span> + </t> + <t t-if="threadView and message.originThread and message.originThread === threadView.thread and message.notifications.length > 0"> + <t t-if="message.failureNotifications.length > 0"> + <span class="o_Message_notificationIconClickable o-error" t-on-click="_onClickFailure"> + <i name="failureIcon" class="o_Message_notificationIcon fa fa-envelope"/> + </span> + </t> + <t t-else=""> + <Popover> + <span class="o_Message_notificationIconClickable"> + <i name="notificationIcon" class="o_Message_notificationIcon fa fa-envelope-o"/> + </span> + <t t-set="opened"> + <NotificationPopover + notificationLocalIds="message.notifications.map(notification => notification.localId)" + /> + </t> + </Popover> + </t> + </t> + <div class="o_Message_commands o_Message_headerCommands" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="!message.isTemporary and ((message.message_type !== 'notification' and message.originThread and message.originThread.model === 'mail.channel') or !message.isTransient) and message.moderation_status !== 'pending_moderation'"> + <span class="o_Message_command o_Message_commandStar o_Message_headerCommand fa" + t-att-class="{ + 'fa-star': message.isStarred, + 'fa-star-o': !message.isStarred, + 'o-message-selected': props.isSelected, + 'o-message-starred': message.isStarred, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickStar" title="Mark as Todo" + /> + </t> + <t t-if="props.hasReplyIcon"> + <span class="o_Message_command o_Message_commandReply o_Message_headerCommand fa fa-reply" + t-att-class="{ + 'o-message-selected': props.isSelected, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickReply" title="Reply" + /> + </t> + <t t-if="props.hasMarkAsReadIcon"> + <span class="o_Message_command o_Message_commandMarkAsRead o_Message_headerCommand fa fa-check" + t-att-class="{ + 'o-message-selected': props.isSelected, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickMarkAsRead" title="Mark as Read" + /> + </t> + </div> + </div> + <t t-if="message.isModeratedByCurrentPartner"> + <div class="o_Message_moderationSubHeader"> + <t t-if="threadView and props.hasCheckbox and message.hasCheckbox"> + <input class="o_Message_checkbox" type="checkbox" t-att-checked="message.isChecked(threadView.thread, threadView.stringifiedDomain) ? 'checked': ''" t-on-change="_onChangeCheckbox" t-ref="checkbox"/> + </t> + <span class="o_Message_moderationPending">Pending moderation:</span> + <a class="o_Message_moderationAction o-accept" href="#" title="Accept" t-on-click="_onClickModerationAccept">Accept</a> + <a class="o_Message_moderationAction o-reject" href="#" title="Remove message with explanation" t-on-click="_onClickModerationReject">Reject</a> + <a class="o_Message_moderationAction o-discard" href="#" title="Remove message without explanation" t-on-click="_onClickModerationDiscard">Discard</a> + <a class="o_Message_moderationAction o-allow" href="#" title="Add this email address to white list of people" t-on-click="_onClickModerationAllow">Always Allow</a> + <a class="o_Message_moderationAction o-ban" href="#" title="Ban this email address" t-on-click="_onClickModerationBan">Ban</a> + </div> + </t> + </t> + <div class="o_Message_content" t-ref="content"> + <div class="o_Message_prettyBody" t-ref="prettyBody"/><!-- message.prettyBody is inserted here from _update() --> + <t t-if="message.subtype_description and !message.isBodyEqualSubtypeDescription"> + <p t-esc="message.subtype_description"/> + </t> + <t t-if="trackingValues.length > 0"> + <ul class="o_Message_trackingValues"> + <t t-foreach="trackingValues" t-as="value" t-key="value.id"> + <li> + <div class="o_Message_trackingValue"> + <div class="o_Message_trackingValueFieldName o_Message_trackingValueItem" t-esc="value.changed_field"/> + <t t-if="value.old_value"> + <div class="o_Message_trackingValueOldValue o_Message_trackingValueItem" t-esc="value.old_value"/> + </t> + <div class="o_Message_trackingValueSeparator o_Message_trackingValueItem fa fa-long-arrow-right" title="Changed" role="img"/> + <t t-if="value.new_value"> + <div class="o_Message_trackingValueNewValue o_Message_trackingValueItem" t-esc="value.new_value"/> + </t> + </div> + </li> + </t> + </ul> + </t> + </div> + <t t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and threadView and threadView.thread and (threadView.thread.mass_mailing or [env.messaging.inbox, env.messaging.history].includes(threadView.thread))"> + <p class="o_Message_subject">Subject: <t t-esc="message.subject"/></p> + </t> + <t t-if="message.attachments and message.attachments.length > 0"> + <div class="o_Message_footer"> + <AttachmentList + class="o_Message_attachmentList" + areAttachmentsDownloadable="true" + areAttachmentsEditable="message.author === env.messaging.currentPartner" + attachmentLocalIds="message.attachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="props.attachmentsDetailsMode" + /> + </div> + </t> + </div> + <t t-if="state.hasModerationBanDialog"> + <ModerationBanDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationBan"/> + </t> + <t t-if="state.hasModerationDiscardDialog"> + <ModerationDiscardDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationDiscard"/> + </t> + <t t-if="state.hasModerationRejectDialog"> + <ModerationRejectDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationReject"/> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message/message_tests.js b/addons/mail/static/src/components/message/message_tests.js new file mode 100644 index 00000000..67fa9b96 --- /dev/null +++ b/addons/mail/static/src/components/message/message_tests.js @@ -0,0 +1,1580 @@ +odoo.define('mail/static/src/components/message/message_tests.js', function (require) { +'use strict'; + +const components = { + Message: require('mail/static/src/components/message/message.js'), +}; +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('message', {}, function () { +QUnit.module('message_tests.js', { + beforeEach() { + beforeEach(this); + + this.createMessageComponent = async (message, otherProps) => { + const props = Object.assign({ messageLocalId: message.localId }, otherProps); + await createRootComponent(this, components.Message, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(12); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a message component" + ); + const messageEl = document.querySelector('.o_Message'); + assert.strictEqual( + messageEl.dataset.messageLocalId, + this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId, + "message component should be linked to message store model" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_sidebar`).length, + 1, + "message should have a sidebar" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_sidebar .o_Message_authorAvatar`).length, + 1, + "message should have author avatar in the sidebar" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorAvatar`).tagName, + 'IMG', + "message author avatar should be an image" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorAvatar`).dataset.src, + '/web/image/res.partner/7/image_128', + "message author avatar should GET image of the related partner" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_authorName`).length, + 1, + "message should display author name" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorName`).textContent, + "Demo User", + "message should display correct author name" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_date`).length, + 1, + "message should display date" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_commands`).length, + 1, + "message should display list of commands" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "message should display the content" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_prettyBody`).innerHTML, + "<p>Test</p>", + "message should display the correct content" + ); +}); + +QUnit.test('moderation: as author, moderated channel with pending moderation message', async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 1, display_name: "Admin" }]], + body: "<p>Test</p>", + id: 100, + moderation_status: 'pending_moderation', + originThread: [['link', thread]], + }); + await this.createMessageComponent(message); + + assert.strictEqual( + document.querySelectorAll(`.o_Message_moderationPending.o-author`).length, + 1, + "should have the message pending moderation" + ); +}); + +QUnit.test('moderation: as moderator, moderated channel with pending moderation message', async function (assert) { + assert.expect(9); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + moderators: [['link', this.env.messaging.currentPartner]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + moderation_status: 'pending_moderation', + originThread: [['link', thread]], + }); + await this.createMessageComponent(message); + const messageEl = document.querySelector('.o_Message'); + assert.ok(messageEl, "should display a message"); + assert.containsOnce(messageEl, `.o_Message_moderationSubHeader`, + "should have the message pending moderation" + ); + assert.containsNone(messageEl, `.o_Message_checkbox`, + "should not have the moderation checkbox by default" + ); + assert.containsN(messageEl, '.o_Message_moderationAction', 5, + "there should be 5 contextual moderation decisions next to the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-accept', + "there should be a contextual moderation decision to accept the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-reject', + "there should be a contextual moderation decision to reject the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-discard', + "there should be a contextual moderation decision to discard the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-allow', + "there should be a contextual moderation decision to allow the user of the message)" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-ban', + "there should be a contextual moderation decision to ban the user of the message" + ); + // The actions are tested as part of discuss tests. +}); + +QUnit.test('Notification Sent', async function (assert) { + assert.expect(9); + + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'sent', + notification_type: 'email', + partner: [['insert', { id: 12, name: "Someone" }]], + }]], + originThread: [['link', threadViewer.thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconClickable', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope-o', + "icon should represent email success" + ); + + await afterNextRender(() => { + document.querySelector('.o_Message_notificationIconClickable').click(); + }); + assert.containsOnce( + document.body, + '.o_NotificationPopover', + "notification popover should be open" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationIcon', + "popover should have one icon" + ); + assert.hasClass( + document.querySelector('.o_NotificationPopover_notificationIcon'), + 'fa-check', + "popover should have the sent icon" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationPartnerName', + "popover should have the partner name" + ); + assert.strictEqual( + document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(), + "Someone", + "partner name should be correct" + ); +}); + +QUnit.test('Notification Error', async function (assert) { + assert.expect(8); + + const openResendActionDef = makeDeferred(); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'mail.mail_resend_message_action', + "action should be the one to resend email" + ); + assert.strictEqual( + payload.options.additional_context.mail_message_to_resend, + 10, + "action should have correct message id" + ); + openResendActionDef.resolve(); + }); + + await this.start({ env: { bus } }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'exception', + notification_type: 'email', + }]], + originThread: [['link', threadViewer.thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconClickable', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope', + "icon should represent email error" + ); + document.querySelector('.o_Message_notificationIconClickable').click(); + await openResendActionDef; + assert.verifySteps( + ['do_action'], + "should do an action to display the resend email dialog" + ); +}); + +QUnit.test("'channel_fetch' notification received is correctly handled", async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel fetched notification + const notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_fetched', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "message seen indicator component should only contain one check (V) as message is just received" + ); +}); + +QUnit.test("'channel_seen' notification received is correctly handled", async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel seen notification + const notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_seen', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message seen indicator component should contain two checks (V) as message is seen" + ); +}); + +QUnit.test("'channel_fetch' notification then 'channel_seen' received are correctly handled", async function (assert) { + assert.expect(4); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel fetched notification + let notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_fetched', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "message seen indicator component should only contain one check (V) as message is just received" + ); + + // Simulate received channel seen notification + notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_seen', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message seen indicator component should contain two checks (V) as message is now seen" + ); +}); + +QUnit.test('do not show messaging seen indicator if not authored by me', async function (assert) { + assert.expect(2); + + await this.start(); + const author = this.env.models['mail.partner'].create({ + id: 100, + display_name: "Demo User" + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + partnerSeenInfos: [['create', [ + { + channelId: 11, + lastFetchedMessage: [['insert', { id: 100 }]], + partnerId: this.env.messaging.currentPartner.id, + }, + { + channelId: 11, + lastFetchedMessage: [['insert', { id: 100 }]], + partnerId: author.id, + }, + ]]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['link', author]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageComponent(message, { threadViewLocalId: threadViewer.threadView.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_Message_seenIndicator', + "message component should not have any message seen indicator" + ); +}); + +QUnit.test('do not show messaging seen indicator if before last seen by all message', async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + messageSeenIndicators: [['insert', { + channelId: 11, + messageId: 99, + }]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const lastSeenMessage = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>You already saw me</p>", + id: 100, + originThread: [['link', thread]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 99, + originThread: [['link', thread]], + }); + thread.update({ + partnerSeenInfos: [['create', [ + { + channelId: 11, + lastSeenMessage: [['link', lastSeenMessage]], + partnerId: this.env.messaging.currentPartner.id, + }, + { + channelId: 11, + lastSeenMessage: [['link', lastSeenMessage]], + partnerId: 100, + }, + ]]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_seenIndicator', + "message component should have a message seen indicator" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V)" + ); +}); + +QUnit.test('only show messaging seen indicator if authored by me, after last seen by all message', async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].insert({ + id: this.env.messaging.currentPartner.id, + display_name: "Demo User" + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 11, + partnerSeenInfos: [['create', [ + { + channelId: 11, + lastSeenMessage: [['insert', { id: 100 }]], + partnerId: this.env.messaging.currentPartner.id, + }, + { + channelId: 11, + lastFetchedMessage: [['insert', { id: 100 }]], + lastSeenMessage: [['insert', { id: 99 }]], + partnerId: 100, + }, + ]]], + messageSeenIndicators: [['insert', { + channelId: 11, + messageId: 100, + }]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_seenIndicator', + "message component should have a message seen indicator" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 1, + "message component should have one check (V) because the message was fetched by everyone but no other member than author has seen the message" + ); +}); + +QUnit.test('allow attachment delete on authored message', async function (assert) { + assert.expect(5); + + await this.start(); + const message = this.env.models['mail.message'].create({ + attachments: [['insert-and-replace', { + filename: "BLAH.jpg", + id: 10, + name: "BLAH", + }]], + author: [['link', this.env.messaging.currentPartner]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + + assert.containsOnce( + document.body, + '.o_Attachment', + "should have an attachment", + ); + assert.containsOnce( + document.body, + '.o_Attachment_asideItemUnlink', + "should have delete attachment button" + ); + + await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click()); + assert.containsOnce( + document.body, + '.o_AttachmentDeleteConfirmDialog', + "An attachment delete confirmation dialog should have been opened" + ); + assert.strictEqual( + document.querySelector('.o_AttachmentDeleteConfirmDialog_mainText').textContent, + `Do you really want to delete "BLAH"?`, + "Confirmation dialog should contain the attachment delete confirmation text" + ); + + await afterNextRender(() => + document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click() + ); + assert.containsNone( + document.body, + '.o_Attachment', + "should no longer have an attachment", + ); +}); + +QUnit.test('prevent attachment delete on non-authored message', async function (assert) { + assert.expect(2); + + await this.start(); + const message = this.env.models['mail.message'].create({ + attachments: [['insert-and-replace', { + filename: "BLAH.jpg", + id: 10, + name: "BLAH", + }]], + author: [['insert', { id: 11, display_name: "Guy" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + + assert.containsOnce( + document.body, + '.o_Attachment', + "should have an attachment", + ); + assert.containsNone( + document.body, + '.o_Attachment_asideItemUnlink', + "delete attachment button should not be printed" + ); +}); + +QUnit.test('subtype description should be displayed if it is different than body', async function (assert) { + assert.expect(2); + + await this.start(); + const message = this.env.models['mail.message'].create({ + body: "<p>Hello</p>", + id: 100, + subtype_description: 'Bonjour', + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_content', + "message should have content" + ); + assert.strictEqual( + document.querySelector(`.o_Message_content`).textContent, + "HelloBonjour", + "message content should display both body and subtype description when they are different" + ); +}); + +QUnit.test('subtype description should not be displayed if it is similar to body', async function (assert) { + assert.expect(2); + + await this.start(); + const message = this.env.models['mail.message'].create({ + body: "<p>Hello</p>", + id: 100, + subtype_description: 'hello', + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_content', + "message should have content" + ); + assert.strictEqual( + document.querySelector(`.o_Message_content`).textContent, + "Hello", + "message content should display only body when subtype description is similar" + ); +}); + +QUnit.test('data-oe-id & data-oe-model link redirection on click', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should open view" + ); + assert.strictEqual( + payload.action.res_model, + 'some.model', + "action should open view on 'some.model' model" + ); + assert.strictEqual( + payload.action.res_id, + 250, + "action should open view on 250" + ); + assert.step('do-action:openFormView_some.model_250'); + }); + await this.start({ env: { bus } }); + const message = this.env.models['mail.message'].create({ + body: `<p><a href="#" data-oe-id="250" data-oe-model="some.model">some.model_250</a></p>`, + id: 100, + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_content', + "message should have content" + ); + assert.containsOnce( + document.querySelector('.o_Message_content'), + 'a', + "message content should have a link" + ); + + document.querySelector(`.o_Message_content a`).click(); + assert.verifySteps( + ['do-action:openFormView_some.model_250'], + "should have open form view on related record after click on link" + ); +}); + +QUnit.test('chat with author should be opened after clicking on his avatar', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 10 }); + this.data['res.users'].records.push({ partner_id: 10 }); + await this.start({ + hasChatWindow: true, + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 10 }]], + id: 10, + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_authorAvatar', + "message should have the author avatar" + ); + assert.hasClass( + document.querySelector('.o_Message_authorAvatar'), + 'o_redirect', + "author avatar should have the redirect style" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_authorAvatar').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_thread', + "chat window with thread should be opened after clicking on author avatar" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow_thread').dataset.correspondentId, + message.author.id.toString(), + "chat with author should be opened after clicking on his avatar" + ); +}); + +QUnit.test('chat with author should be opened after clicking on his im status icon', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 10 }); + this.data['res.users'].records.push({ partner_id: 10 }); + await this.start({ + hasChatWindow: true, + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 10, im_status: 'online' }]], + id: 10, + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_partnerImStatusIcon', + "message should have the author im status icon" + ); + assert.hasClass( + document.querySelector('.o_Message_partnerImStatusIcon'), + 'o-has-open-chat', + "author im status icon should have the open chat style" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_partnerImStatusIcon').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_thread', + "chat window with thread should be opened after clicking on author im status icon" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow_thread').dataset.correspondentId, + message.author.id.toString(), + "chat with author should be opened after clicking on his im status icon" + ); +}); + +QUnit.test('open chat with author on avatar click should be disabled when currently chatting with the author', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ + channel_type: 'chat', + members: [this.data.currentPartnerId, 10], + public: 'private', + }); + this.data['res.partner'].records.push({ id: 10 }); + this.data['res.users'].records.push({ partner_id: 10 }); + await this.start({ + hasChatWindow: true, + }); + const correspondent = this.env.models['mail.partner'].insert({ id: 10 }); + const message = this.env.models['mail.message'].create({ + author: [['link', correspondent]], + id: 10, + }); + const thread = await correspondent.getChat(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId, + }); + assert.containsOnce( + document.body, + '.o_Message_authorAvatar', + "message should have the author avatar" + ); + assert.doesNotHaveClass( + document.querySelector('.o_Message_authorAvatar'), + 'o_redirect', + "author avatar should not have the redirect style" + ); + + document.querySelector('.o_Message_authorAvatar').click(); + await nextAnimationFrame(); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should have no thread opened after clicking on author avatar when currently chatting with the author" + ); +}); + +QUnit.test('basic rendering of tracking value (float type)', async function (assert) { + assert.expect(8); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "float", + id: 6, + new_value: 45.67, + old_value: 12.3, + }], + }); + await this.createMessageComponent(message); + assert.containsOnce( + document.body, + '.o_Message_trackingValue', + "should display a tracking value" + ); + assert.containsOnce( + document.body, + '.o_Message_trackingValueFieldName', + "should display the name of the tracked field" + ); + assert.strictEqual( + document.querySelector('.o_Message_trackingValueFieldName').textContent, + "Total:", + "should display the correct tracked field name (Total)", + ); + assert.containsOnce( + document.body, + '.o_Message_trackingValueOldValue', + "should display the old value" + ); + assert.strictEqual( + document.querySelector('.o_Message_trackingValueOldValue').textContent, + "12.30", + "should display the correct old value (12.30)", + ); + assert.containsOnce( + document.body, + '.o_Message_trackingValueSeparator', + "should display the separator" + ); + assert.containsOnce( + document.body, + '.o_Message_trackingValueNewValue', + "should display the new value" + ); + assert.strictEqual( + document.querySelector('.o_Message_trackingValueNewValue').textContent, + "45.67", + "should display the correct new value (45.67)", + ); +}); + +QUnit.test('rendering of tracked field of type integer: from non-0 to 0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "integer", + id: 6, + new_value: 0, + old_value: 1, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:10", + "should display the correct content of tracked field of type integer: from non-0 to 0 (Total: 1 -> 0)" + ); +}); + +QUnit.test('rendering of tracked field of type integer: from 0 to non-0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "integer", + id: 6, + new_value: 1, + old_value: 0, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:01", + "should display the correct content of tracked field of type integer: from 0 to non-0 (Total: 0 -> 1)" + ); +}); + +QUnit.test('rendering of tracked field of type float: from non-0 to 0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "float", + id: 6, + new_value: 0, + old_value: 1, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:1.000.00", + "should display the correct content of tracked field of type float: from non-0 to 0 (Total: 1.00 -> 0.00)" + ); +}); + +QUnit.test('rendering of tracked field of type float: from 0 to non-0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "float", + id: 6, + new_value: 1, + old_value: 0, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:0.001.00", + "should display the correct content of tracked field of type float: from 0 to non-0 (Total: 0.00 -> 1.00)" + ); +}); + +QUnit.test('rendering of tracked field of type monetary: from non-0 to 0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "monetary", + id: 6, + new_value: 0, + old_value: 1, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:1.000.00", + "should display the correct content of tracked field of type monetary: from non-0 to 0 (Total: 1.00 -> 0.00)" + ); +}); + +QUnit.test('rendering of tracked field of type monetary: from 0 to non-0', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Total", + field_type: "monetary", + id: 6, + new_value: 1, + old_value: 0, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Total:0.001.00", + "should display the correct content of tracked field of type monetary: from 0 to non-0 (Total: 0.00 -> 1.00)" + ); +}); + +QUnit.test('rendering of tracked field of type boolean: from true to false', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Is Ready", + field_type: "boolean", + id: 6, + new_value: false, + old_value: true, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Is Ready:TrueFalse", + "should display the correct content of tracked field of type boolean: from true to false (Is Ready: True -> False)" + ); +}); + +QUnit.test('rendering of tracked field of type boolean: from false to true', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Is Ready", + field_type: "boolean", + id: 6, + new_value: true, + old_value: false, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Is Ready:FalseTrue", + "should display the correct content of tracked field of type boolean: from false to true (Is Ready: False -> True)" + ); +}); + +QUnit.test('rendering of tracked field of type char: from a string to empty string', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Name", + field_type: "char", + id: 6, + new_value: "", + old_value: "Marc", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Name:Marc", + "should display the correct content of tracked field of type char: from a string to empty string (Name: Marc ->)" + ); +}); + +QUnit.test('rendering of tracked field of type char: from empty string to a string', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Name", + field_type: "char", + id: 6, + new_value: "Marc", + old_value: "", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Name:Marc", + "should display the correct content of tracked field of type char: from empty string to a string (Name: -> Marc)" + ); +}); + +QUnit.test('rendering of tracked field of type date: from no date to a set date', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Deadline", + field_type: "date", + id: 6, + new_value: "2018-12-14", + old_value: false, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Deadline:12/14/2018", + "should display the correct content of tracked field of type date: from no date to a set date (Deadline: -> 12/14/2018)" + ); +}); + +QUnit.test('rendering of tracked field of type date: from a set date to no date', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Deadline", + field_type: "date", + id: 6, + new_value: false, + old_value: "2018-12-14", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Deadline:12/14/2018", + "should display the correct content of tracked field of type date: from a set date to no date (Deadline: 12/14/2018 ->)" + ); +}); + +QUnit.test('rendering of tracked field of type datetime: from no date and time to a set date and time', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Deadline", + field_type: "datetime", + id: 6, + new_value: "2018-12-14 13:42:28", + old_value: false, + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Deadline:12/14/2018 13:42:28", + "should display the correct content of tracked field of type datetime: from no date and time to a set date and time (Deadline: -> 12/14/2018 13:42:28)" + ); +}); + +QUnit.test('rendering of tracked field of type datetime: from a set date and time to no date and time', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Deadline", + field_type: "datetime", + id: 6, + new_value: false, + old_value: "2018-12-14 13:42:28", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Deadline:12/14/2018 13:42:28", + "should display the correct content of tracked field of type datetime: from a set date and time to no date and time (Deadline: 12/14/2018 13:42:28 ->)" + ); +}); + +QUnit.test('rendering of tracked field of type text: from some text to empty', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Name", + field_type: "text", + id: 6, + new_value: "", + old_value: "Marc", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Name:Marc", + "should display the correct content of tracked field of type text: from some text to empty (Name: Marc ->)" + ); +}); + +QUnit.test('rendering of tracked field of type text: from empty to some text', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Name", + field_type: "text", + id: 6, + new_value: "Marc", + old_value: "", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Name:Marc", + "should display the correct content of tracked field of type text: from empty to some text (Name: -> Marc)" + ); +}); + +QUnit.test('rendering of tracked field of type selection: from a selection to no selection', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "State", + field_type: "selection", + id: 6, + new_value: "", + old_value: "ok", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "State:ok", + "should display the correct content of tracked field of type selection: from a selection to no selection (State: ok ->)" + ); +}); + +QUnit.test('rendering of tracked field of type selection: from no selection to a selection', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "State", + field_type: "selection", + id: 6, + new_value: "ok", + old_value: "", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "State:ok", + "should display the correct content of tracked field of type selection: from no selection to a selection (State: -> ok)" + ); +}); + +QUnit.test('rendering of tracked field of type many2one: from having a related record to no related record', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Author", + field_type: "many2one", + id: 6, + new_value: "", + old_value: "Marc", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Author:Marc", + "should display the correct content of tracked field of type many2one: from having a related record to no related record (Author: Marc ->)" + ); +}); + +QUnit.test('rendering of tracked field of type many2one: from no related record to having a related record', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 11, + tracking_value_ids: [{ + changed_field: "Author", + field_type: "many2one", + id: 6, + new_value: "Marc", + old_value: "", + }], + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelector('.o_Message_trackingValue').textContent, + "Author:Marc", + "should display the correct content of tracked field of type many2one: from no related record to having a related record (Author: -> Marc)" + ); +}); + +QUnit.test('message should not be considered as "clicked" after clicking on its author name', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + document.querySelector(`.o_Message_authorName`).click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on its author name" + ); +}); + +QUnit.test('message should not be considered as "clicked" after clicking on its author avatar', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + document.querySelector(`.o_Message_authorAvatar`).click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on its author avatar" + ); +}); + +QUnit.test('message should not be considered as "clicked" after clicking on notification failure icon', async function (assert) { + assert.expect(1); + + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'exception', + notification_type: 'email', + }]], + originThread: [['link', threadViewer.thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + document.querySelector('.o_Message_notificationIconClickable.o-error').click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on notification failure icon" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js new file mode 100644 index 00000000..21fb18a5 --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js @@ -0,0 +1,67 @@ +odoo.define('mail/static/src/components/message_author_prefix/message_author_prefix.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MessageAuthorPrefix extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const author = message ? message.author : undefined; + const thread = props.threadLocalId + ? this.env.models['mail.thread'].get(props.threadLocalId) + : undefined; + return { + author: author ? author.__state : undefined, + currentPartner: this.env.messaging.currentPartner + ? this.env.messaging.currentPartner.__state + : undefined, + message: message ? message.__state : undefined, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + + /** + * @returns {mail.thread|undefined} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(MessageAuthorPrefix, { + props: { + messageLocalId: String, + threadLocalId: { + type: String, + optional: true, + }, + }, + template: 'mail.MessageAuthorPrefix', +}); + +return MessageAuthorPrefix; + +}); diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss new file mode 100644 index 00000000..362eaeb5 --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss @@ -0,0 +1,11 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageAuthorPrefixIcon { + margin-right: 3px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml new file mode 100644 index 00000000..eddc2b01 --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageAuthorPrefix" owl="1"> + <span class="o_MessageAuthorPrefix"> + <t t-if="message"> + <t t-if="message.author and message.author === env.messaging.currentPartner"> + <i class="o_MessageAuthorPrefixIcon fa fa-mail-reply"/>You: + </t> + <t t-elif="thread and message.author !== thread.correspondent"> + <t t-esc="message.author.nameOrDisplayName"/>: + </t> + </t> + </span> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js new file mode 100644 index 00000000..245fd335 --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.js @@ -0,0 +1,600 @@ +odoo.define('mail/static/src/components/message_list/message_list.js', function (require) { +'use strict'; + +const components = { + Message: require('mail/static/src/components/message/message.js'), +}; +const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js'); +const useRenderedValues = require('mail/static/src/component_hooks/use_rendered_values/use_rendered_values.js'); +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class MessageList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId); + const thread = threadView ? threadView.thread : undefined; + const threadCache = threadView ? threadView.threadCache : undefined; + return { + isDeviceMobile: this.env.messaging.device.isMobile, + thread, + threadCache, + threadCacheIsAllHistoryLoaded: threadCache && threadCache.isAllHistoryLoaded, + threadCacheIsLoaded: threadCache && threadCache.isLoaded, + threadCacheIsLoadingMore: threadCache && threadCache.isLoadingMore, + threadCacheLastMessage: threadCache && threadCache.lastMessage, + threadCacheOrderedMessages: threadCache ? threadCache.orderedMessages : [], + threadIsTemporary: thread && thread.isTemporary, + threadMainCache: thread && thread.mainCache, + threadMessageAfterNewMessageSeparator: thread && thread.messageAfterNewMessageSeparator, + threadViewComponentHintList: threadView ? threadView.componentHintList : [], + threadViewNonEmptyMessagesLength: threadView && threadView.nonEmptyMessages.length, + }; + }, { + compareDepth: { + threadCacheOrderedMessages: 1, + threadViewComponentHintList: 1, + }, + }); + this._getRefs = useRefs(); + /** + * States whether there was at least one programmatic scroll since the + * last scroll event was handled (which is particularly async due to + * throttled behavior). + * Useful to avoid loading more messages or to incorrectly disabling the + * auto-scroll feature when the scroll was not made by the user. + */ + this._isLastScrollProgrammatic = false; + /** + * Reference of the "load more" item. Useful to trigger load more + * on scroll when it becomes visible. + */ + this._loadMoreRef = useRef('loadMore'); + /** + * Snapshot computed during willPatch, which is used by patched. + */ + this._willPatchSnapshot = undefined; + this._onScrollThrottled = _.throttle(this._onScrollThrottled.bind(this), 100); + /** + * State used by the component at the time of the render. Useful to + * properly handle async code. + */ + this._lastRenderedValues = useRenderedValues(() => { + const threadView = this.threadView; + const thread = threadView && threadView.thread; + const threadCache = threadView && threadView.threadCache; + return { + componentHintList: threadView ? [...threadView.componentHintList] : [], + hasAutoScrollOnMessageReceived: threadView && threadView.hasAutoScrollOnMessageReceived, + hasScrollAdjust: this.props.hasScrollAdjust, + mainCache: thread && thread.mainCache, + order: this.props.order, + orderedMessages: threadCache ? [...threadCache.orderedMessages] : [], + thread, + threadCache, + threadCacheInitialScrollHeight: threadView && threadView.threadCacheInitialScrollHeight, + threadCacheInitialScrollPosition: threadView && threadView.threadCacheInitialScrollPosition, + threadView, + threadViewer: threadView && threadView.threadViewer, + }; + }); + // useUpdate must be defined after useRenderedValues to guarantee proper + // call order + useUpdate({ func: () => this._update() }); + } + + willPatch() { + const lastMessageRef = this.lastMessageRef; + this._willPatchSnapshot = { + isLastMessageVisible: + lastMessageRef && + lastMessageRef.isBottomVisible({ offset: 10 }), + scrollHeight: this._getScrollableElement().scrollHeight, + scrollTop: this._getScrollableElement().scrollTop, + }; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Update the scroll position of the message list. + * This is not done in patched/mounted hooks because scroll position is + * dependent on UI globally. To illustrate, imagine following UI: + * + * +----------+ < viewport top = scrollable top + * | message | + * | list | + * | | + * +----------+ < scrolltop = viewport bottom = scrollable bottom + * + * Now if a composer is mounted just below the message list, it is shrinked + * and scrolltop is altered as a result: + * + * +----------+ < viewport top = scrollable top + * | message | + * | list | < scrolltop = viewport bottom <-+ + * | | |-- dist = composer height + * +----------+ < scrollable bottom <-+ + * +----------+ + * | composer | + * +----------+ + * + * Because of this, the scroll position must be changed when whole UI + * is rendered. To make this simpler, this is done when <ThreadView/> + * component is patched. This is acceptable when <ThreadView/> has a + * fixed height, which is the case for the moment. task-2358066 + */ + adjustFromComponentHints() { + const { componentHintList, threadView } = this._lastRenderedValues(); + for (const hint of componentHintList) { + switch (hint.type) { + case 'change-of-thread-cache': + case 'home-menu-hidden': + case 'home-menu-shown': + // thread just became visible, the goal is to restore its + // saved position if it exists or scroll to the end + this._adjustScrollFromModel(); + break; + case 'message-received': + case 'messages-loaded': + case 'new-messages-loaded': + // messages have been added at the end, either scroll to the + // end or keep the current position + this._adjustScrollForExtraMessagesAtTheEnd(); + break; + case 'more-messages-loaded': + // messages have been added at the start, keep the current + // position + this._adjustScrollForExtraMessagesAtTheStart(); + break; + } + if (threadView && threadView.exists()) { + threadView.markComponentHintProcessed(hint); + } + } + this._willPatchSnapshot = undefined; + } + + /** + * @param {mail.message} message + * @returns {string} + */ + getDateDay(message) { + const date = message.date.format('YYYY-MM-DD'); + if (date === moment().format('YYYY-MM-DD')) { + return this.env._t("Today"); + } else if ( + date === moment() + .subtract(1, 'days') + .format('YYYY-MM-DD') + ) { + return this.env._t("Yesterday"); + } + return message.date.format('LL'); + } + + /** + * @returns {integer} + */ + getScrollHeight() { + return this._getScrollableElement().scrollHeight; + } + + /** + * @returns {integer} + */ + getScrollTop() { + return this._getScrollableElement().scrollTop; + } + + /** + * @returns {mail/static/src/components/message/message.js|undefined} + */ + get mostRecentMessageRef() { + const { order } = this._lastRenderedValues(); + if (order === 'desc') { + return this.messageRefs[0]; + } + const { length: l, [l - 1]: mostRecentMessageRef } = this.messageRefs; + return mostRecentMessageRef; + } + + /** + * @param {integer} messageId + * @returns {mail/static/src/components/message/message.js|undefined} + */ + messageRefFromId(messageId) { + return this.messageRefs.find(ref => ref.message.id === messageId); + } + + /** + * Get list of sub-components Message, ordered based on prop `order` + * (ASC/DESC). + * + * The asynchronous nature of OWL rendering pipeline may reveal disparity + * between knowledgeable state of store between components. Use this getter + * with extreme caution! + * + * Let's illustrate the disparity with a small example: + * + * - Suppose this component is aware of ordered (record) messages with + * following IDs: [1, 2, 3, 4, 5], and each (sub-component) messages map + * each of these records. + * - Now let's assume a change in store that translate to ordered (record) + * messages with following IDs: [2, 3, 4, 5, 6]. + * - Because store changes trigger component re-rendering by their "depth" + * (i.e. from parents to children), this component may be aware of + * [2, 3, 4, 5, 6] but not yet sub-components, so that some (component) + * messages should be destroyed but aren't yet (the ref with message ID 1) + * and some do not exist yet (no ref with message ID 6). + * + * @returns {mail/static/src/components/message/message.js[]} + */ + get messageRefs() { + const { order } = this._lastRenderedValues(); + const refs = this._getRefs(); + const ascOrderedMessageRefs = Object.entries(refs) + .filter(([refId, ref]) => ( + // Message refs have message local id as ref id, and message + // local ids contain name of model 'mail.message'. + refId.includes(this.env.models['mail.message'].modelName) && + // Component that should be destroyed but haven't just yet. + ref.message + ) + ) + .map(([refId, ref]) => ref) + .sort((ref1, ref2) => (ref1.message.id < ref2.message.id ? -1 : 1)); + if (order === 'desc') { + return ascOrderedMessageRefs.reverse(); + } + return ascOrderedMessageRefs; + } + + /** + * @returns {mail.message[]} + */ + get orderedMessages() { + const threadCache = this.threadView.threadCache; + if (this.props.order === 'desc') { + return [...threadCache.orderedMessages].reverse(); + } + return threadCache.orderedMessages; + } + + /** + * @param {integer} value + */ + setScrollTop(value) { + if (this._getScrollableElement().scrollTop === value) { + return; + } + this._isLastScrollProgrammatic = true; + this._getScrollableElement().scrollTop = value; + } + + /** + * @param {mail.message} prevMessage + * @param {mail.message} message + * @returns {boolean} + */ + shouldMessageBeSquashed(prevMessage, message) { + if (!this.props.hasSquashCloseMessages) { + return false; + } + if (Math.abs(message.date.diff(prevMessage.date)) > 60000) { + // more than 1 min. elasped + return false; + } + if (prevMessage.message_type !== 'comment' || message.message_type !== 'comment') { + return false; + } + if (prevMessage.author !== message.author) { + // from a different author + return false; + } + if (prevMessage.originThread !== message.originThread) { + return false; + } + if ( + prevMessage.moderation_status === 'pending_moderation' || + message.moderation_status === 'pending_moderation' + ) { + return false; + } + if ( + prevMessage.notifications.length > 0 || + message.notifications.length > 0 + ) { + // visual about notifications is restricted to non-squashed messages + return false; + } + const prevOriginThread = prevMessage.originThread; + const originThread = message.originThread; + if ( + prevOriginThread && + originThread && + prevOriginThread.model === originThread.model && + originThread.model !== 'mail.channel' && + prevOriginThread.id !== originThread.id + ) { + // messages linked to different document thread + return false; + } + return true; + } + + /** + * @returns {mail.thread_view} + */ + get threadView() { + return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _adjustScrollForExtraMessagesAtTheEnd() { + const { + hasAutoScrollOnMessageReceived, + hasScrollAdjust, + order, + } = this._lastRenderedValues(); + if (!this._getScrollableElement() || !hasScrollAdjust) { + return; + } + if (!hasAutoScrollOnMessageReceived) { + if (order === 'desc' && this._willPatchSnapshot) { + const { scrollHeight, scrollTop } = this._willPatchSnapshot; + this.setScrollTop(this._getScrollableElement().scrollHeight - scrollHeight + scrollTop); + } + return; + } + this._scrollToEnd(); + } + + /** + * @private + */ + _adjustScrollForExtraMessagesAtTheStart() { + const { + hasScrollAdjust, + order, + } = this._lastRenderedValues(); + if ( + !this._getScrollableElement() || + !hasScrollAdjust || + !this._willPatchSnapshot || + order === 'desc' + ) { + return; + } + const { scrollHeight, scrollTop } = this._willPatchSnapshot; + this.setScrollTop(this._getScrollableElement().scrollHeight - scrollHeight + scrollTop); + } + + /** + * @private + */ + _adjustScrollFromModel() { + const { + hasScrollAdjust, + threadCacheInitialScrollHeight, + threadCacheInitialScrollPosition, + } = this._lastRenderedValues(); + if (!this._getScrollableElement() || !hasScrollAdjust) { + return; + } + if ( + threadCacheInitialScrollPosition !== undefined && + this._getScrollableElement().scrollHeight === threadCacheInitialScrollHeight + ) { + this.setScrollTop(threadCacheInitialScrollPosition); + return; + } + this._scrollToEnd(); + return; + } + + /** + * @private + */ + _checkMostRecentMessageIsVisible() { + const { + mainCache, + threadCache, + threadView, + } = this._lastRenderedValues(); + if (!threadView || !threadView.exists()) { + return; + } + const lastMessageIsVisible = + threadCache && + this.mostRecentMessageRef && + threadCache === mainCache && + this.mostRecentMessageRef.isPartiallyVisible(); + if (lastMessageIsVisible) { + threadView.handleVisibleMessage(this.mostRecentMessageRef.message); + } + } + + /** + * @private + * @returns {Element|undefined} Scrollable Element + */ + _getScrollableElement() { + if (this.props.getScrollableElement) { + return this.props.getScrollableElement(); + } else { + return this.el; + } + } + + /** + * @private + * @returns {boolean} + */ + _isLoadMoreVisible() { + const loadMore = this._loadMoreRef.el; + if (!loadMore) { + return false; + } + const loadMoreRect = loadMore.getBoundingClientRect(); + const elRect = this._getScrollableElement().getBoundingClientRect(); + const isInvisible = loadMoreRect.top > elRect.bottom || loadMoreRect.bottom < elRect.top; + return !isInvisible; + } + + /** + * @private + */ + _loadMore() { + const { threadCache } = this._lastRenderedValues(); + if (!threadCache || !threadCache.exists()) { + return; + } + threadCache.loadMoreMessages(); + } + + /** + * Scrolls to the end of the list. + * + * @private + */ + _scrollToEnd() { + const { order } = this._lastRenderedValues(); + this.setScrollTop(order === 'asc' ? this._getScrollableElement().scrollHeight - this._getScrollableElement().clientHeight : 0); + } + + /** + * @private + */ + _update() { + this._checkMostRecentMessageIsVisible(); + this.adjustFromComponentHints(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickLoadMore(ev) { + ev.preventDefault(); + this._loadMore(); + } + + /** + * @private + * @param {ScrollEvent} ev + */ + onScroll(ev) { + this._onScrollThrottled(ev); + } + + /** + * @private + * @param {ScrollEvent} ev + */ + _onScrollThrottled(ev) { + const { + order, + orderedMessages, + thread, + threadCache, + threadView, + threadViewer, + } = this._lastRenderedValues(); + if (!this._getScrollableElement()) { + // could be unmounted in the meantime (due to throttled behavior) + return; + } + const scrollTop = this._getScrollableElement().scrollTop; + this.env.messagingBus.trigger('o-component-message-list-scrolled', { + orderedMessages, + scrollTop, + thread, + threadViewer, + }); + if (!this._isLastScrollProgrammatic && threadView && threadView.exists()) { + // Margin to compensate for inaccurate scrolling to bottom and height + // flicker due height change of composer area. + const margin = 30; + // Automatically scroll to new received messages only when the list is + // currently fully scrolled. + const hasAutoScrollOnMessageReceived = (order === 'asc') + ? scrollTop >= this._getScrollableElement().scrollHeight - this._getScrollableElement().clientHeight - margin + : scrollTop <= margin; + threadView.update({ hasAutoScrollOnMessageReceived }); + } + if (threadViewer && threadViewer.exists()) { + threadViewer.saveThreadCacheScrollHeightAsInitial(this._getScrollableElement().scrollHeight, threadCache); + threadViewer.saveThreadCacheScrollPositionsAsInitial(scrollTop, threadCache); + } + if (!this._isLastScrollProgrammatic && this._isLoadMoreVisible()) { + this._loadMore(); + } + this._checkMostRecentMessageIsVisible(); + this._isLastScrollProgrammatic = false; + } + +} + +Object.assign(MessageList, { + components, + defaultProps: { + hasMessageCheckbox: false, + hasScrollAdjust: true, + hasSquashCloseMessages: false, + haveMessagesMarkAsReadIcon: false, + haveMessagesReplyIcon: false, + order: 'asc', + }, + props: { + hasMessageCheckbox: Boolean, + hasSquashCloseMessages: Boolean, + haveMessagesMarkAsReadIcon: Boolean, + haveMessagesReplyIcon: Boolean, + hasScrollAdjust: Boolean, + /** + * Function returns the exact scrollable element from the parent + * to manage proper scroll heights which affects the load more messages. + */ + getScrollableElement: { + type: Function, + optional: true, + }, + order: { + type: String, + validate: prop => ['asc', 'desc'].includes(prop), + }, + selectedMessageLocalId: { + type: String, + optional: true, + }, + threadViewLocalId: String, + }, + template: 'mail.MessageList', +}); + +return MessageList; + +}); diff --git a/addons/mail/static/src/components/message_list/message_list.scss b/addons/mail/static/src/components/message_list/message_list.scss new file mode 100644 index 00000000..cb06adda --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.scss @@ -0,0 +1,135 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageList { + display: flex; + flex-flow: column; + overflow: auto; + + &.o-empty { + align-items: center; + justify-content: center; + } + + &:not(.o-empty) { + padding-bottom: 15px; + } +} + +.o_MessageList_empty { + flex: 1 1 auto; + height: 100%; + width: 100%; + align-self: center; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + padding: 20px; + line-height: 2.5rem; +} + +.o_MessageList_isLoadingMore { + align-self: center; +} + +.o_MessageList_isLoadingMoreIcon { + margin-right: 3px; +} + +.o_MessageList_loadMore { + align-self: center; +} + +.o_MessageList_separator { + display: flex; + align-items: center; + padding: 0 0; + flex: 0 0 auto; +} + +.o_MessageList_separatorDate { + padding: 15px 0; +} + +.o_MessageList_separatorLine { + flex: 1 1 auto; + width: auto; +} + +.o_MessageList_separatorNewMessages { + // bug with safari: container does not auto-grow from child size + padding: 0 0; + margin-right: 15px; +} + +.o_MessageList_separatorLabel { + padding: 0 10px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MessageList { + background-color: white; +} + +.o_MessageList_empty { + text-align: center; +} + +.o_MessageList_emptyTitle { + font-weight: bold; + font-size: 1.3rem; + + &.o-neutral-face-icon:before { + @extend %o-nocontent-init-image; + @include size(120px, 140px); + background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center; + } +} + +.o_MessageList_loadMore { + cursor: pointer; +} + +.o_MessageList_message.o-has-message-selection:not(.o-selected) { + opacity: 0.5; +} + +.o_MessageList_separator { + font-weight: bold; +} + +.o_MessageList_separatorLine { + border-color: gray('400'); +} + +.o_MessageList_separatorLineNewMessages { + border-color: lighten($o-brand-odoo, 15%); +} + +.o_MessageList_separatorNewMessages { + color: lighten($o-brand-odoo, 15%); + +} + +.o_MessageList_separatorLabel { + background-color: white; +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_MessageList_separatorNewMessages:not(.o-disable-animation) { + &.fade-leave-active { + transition: opacity 0.5s; + } + + &.fade-leave-to { + opacity: 0; + } +} diff --git a/addons/mail/static/src/components/message_list/message_list.xml b/addons/mail/static/src/components/message_list/message_list.xml new file mode 100644 index 00000000..c0aff715 --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.xml @@ -0,0 +1,103 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageList" owl="1"> + <div class="o_MessageList" t-att-class="{ 'o-empty': threadView and threadView.messages.length === 0, 'o-has-message-selection': props.selectedMessageLocalId }" t-on-scroll="onScroll"> + <t t-if="threadView"> + <!-- No result messages --> + <t t-if="threadView.nonEmptyMessages.length === 0"> + <div class="o_MessageList_empty o_MessageList_item"> + <t t-if="threadView.thread === env.messaging.inbox"> + <div class="o_MessageList_emptyTitle"> + Congratulations, your inbox is empty + </div> + New messages appear here. + </t> + <t t-elif="threadView.thread === env.messaging.starred"> + <div class="o_MessageList_emptyTitle"> + No starred messages + </div> + You can mark any message as 'starred', and it shows up in this mailbox. + </t> + <t t-elif="threadView.thread === env.messaging.history"> + <div class="o_MessageList_emptyTitle o-neutral-face-icon"> + No history messages + </div> + Messages marked as read will appear in the history. + </t> + <t t-elif="threadView.thread === env.messaging.moderation"> + <div class="o_MessageList_emptyTitle"> + You have no messages to moderate. + </div> + Messages pending moderation appear here. + </t> + <t t-else=""> + There are no messages in this conversation. + </t> + </div> + </t> + <!-- LOADING (if order asc)--> + <t t-if="props.order === 'asc' and orderedMessages.length > 0"> + <t t-call="mail.MessageList.loadMore"/> + </t> + <!-- MESSAGES --> + <t t-set="current_day" t-value="0"/> + <t t-set="prev_message" t-value="0"/> + <t t-foreach="orderedMessages" t-as="message" t-key="message.localId"> + <t t-if="message === threadView.thread.messageAfterNewMessageSeparator"> + <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade"> + <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span> + </div> + </t> + <t t-if="!message.isEmpty"> + <t t-set="message_day" t-value="getDateDay(message)"/> + <t t-if="current_day !== message_day"> + <div class="o_MessageList_separator o_MessageList_separatorDate o_MessageList_item"> + <hr class="o_MessageList_separatorLine"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelDate"><t t-esc="message_day"/></span><hr class="o_MessageList_separatorLine"/> + <t t-set="current_day" t-value="message_day"/> + <t t-set="isMessageSquashed" t-value="false"/> + </div> + </t> + <t t-else=""> + <t t-set="isMessageSquashed" t-value="shouldMessageBeSquashed(prev_message, message)"/> + </t> + <Message + class="o_MessageList_item o_MessageList_message" + t-att-class="{ + 'o-has-message-selection': props.selectedMessageLocalId, + }" + hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon" + hasCheckbox="props.hasMessageCheckbox" + hasReplyIcon="props.haveMessagesReplyIcon" + isSelected="props.selectedMessageLocalId === message.localId" + isSquashed="isMessageSquashed" + messageLocalId="message.localId" + threadViewLocalId="threadView.localId" + t-ref="{{ message.localId }}" + /> + <t t-set="prev_message" t-value="message"/> + </t> + </t> + <!-- LOADING (if order desc)--> + <t t-if="props.order === 'desc' and orderedMessages.length > 0"> + <t t-call="mail.MessageList.loadMore"/> + </t> + </t> + </div> + </t> + + <t t-name="mail.MessageList.loadMore" owl="1"> + <t t-if="threadView.threadCache.isLoadingMore"> + <div class="o_MessageList_item o_MessageList_isLoadingMore"> + <i class="o_MessageList_isLoadingMoreIcon fa fa-spin fa-spinner"/> + Loading... + </div> + </t> + <t t-elif="!threadView.threadCache.isAllHistoryLoaded and !threadView.thread.isTemporary"> + <a class="o_MessageList_item o_MessageList_loadMore" href="#" t-on-click="_onClickLoadMore" t-ref="loadMore"> + Load more + </a> + </t> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js new file mode 100644 index 00000000..ed555b0c --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js @@ -0,0 +1,136 @@ +odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MessageSeenIndicator extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const messageSeenIndicator = thread && thread.model === 'mail.channel' + ? this.env.models['mail.message_seen_indicator'].findFromIdentifyingData({ + channelId: thread.id, + messageId: message.id, + }) + : undefined; + return { + messageSeenIndicator: messageSeenIndicator ? messageSeenIndicator.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get indicatorTitle() { + if (!this.messageSeenIndicator) { + return ''; + } + if (this.messageSeenIndicator.hasEveryoneSeen) { + return this.env._t("Seen by Everyone"); + } + if (this.messageSeenIndicator.hasSomeoneSeen) { + const partnersThatHaveSeen = this.messageSeenIndicator.partnersThatHaveSeen.map( + partner => partner.name + ); + if (partnersThatHaveSeen.length === 1) { + return _.str.sprintf( + this.env._t("Seen by %s"), + partnersThatHaveSeen[0] + ); + } + if (partnersThatHaveSeen.length === 2) { + return _.str.sprintf( + this.env._t("Seen by %s and %s"), + partnersThatHaveSeen[0], + partnersThatHaveSeen[1] + ); + } + return _.str.sprintf( + this.env._t("Seen by %s, %s and more"), + partnersThatHaveSeen[0], + partnersThatHaveSeen[1] + ); + } + if (this.messageSeenIndicator.hasEveryoneFetched) { + return this.env._t("Received by Everyone"); + } + if (this.messageSeenIndicator.hasSomeoneFetched) { + const partnersThatHaveFetched = this.messageSeenIndicator.partnersThatHaveFetched.map( + partner => partner.name + ); + if (partnersThatHaveFetched.length === 1) { + return _.str.sprintf( + this.env._t("Received by %s"), + partnersThatHaveFetched[0] + ); + } + if (partnersThatHaveFetched.length === 2) { + return _.str.sprintf( + this.env._t("Received by %s and %s"), + partnersThatHaveFetched[0], + partnersThatHaveFetched[1] + ); + } + return _.str.sprintf( + this.env._t("Received by %s, %s and more"), + partnersThatHaveFetched[0], + partnersThatHaveFetched[1] + ); + } + return ''; + } + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + + /** + * @returns {mail.message_seen_indicator} + */ + get messageSeenIndicator() { + if (!this.thread || this.thread.model !== 'mail.channel') { + return undefined; + } + return this.env.models['mail.message_seen_indicator'].findFromIdentifyingData({ + channelId: this.thread.id, + messageId: this.message.id, + }); + } + + /** + * @returns {mail.Thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } +} + +Object.assign(MessageSeenIndicator, { + props: { + messageLocalId: String, + threadLocalId: String, + }, + template: 'mail.MessageSeenIndicator', +}); + +return MessageSeenIndicator; + +}); diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss new file mode 100644 index 00000000..3a9d566e --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageSeenIndicator { + display: flex; + position: relative; + flex-wrap: nowrap; +} + +.o_MessageSeenIndicator_icon { + + &.o-first { + padding-left: map-get($spacers, 1); + } + + &.o-second { + position: absolute; + top: -1px; + left: -1px; + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MessageSeenIndicator { + opacity: 0.6; + + &.o-all-seen { + color: $o-brand-odoo; + } + + &:hover { + cursor: pointer; + opacity: 0.8; + } +} diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml new file mode 100644 index 00000000..e905afaa --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageSeenIndicator" owl="1"> + <span class="o_MessageSeenIndicator" t-att-class="{ 'o-all-seen': messageSeenIndicator and messageSeenIndicator.hasEveryoneSeen }" t-att-title="indicatorTitle"> + <t t-if="messageSeenIndicator and !messageSeenIndicator.isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone"> + <t t-if="messageSeenIndicator.hasSomeoneFetched or messageSeenIndicator.hasSomeoneSeen"> + <i class="o_MessageSeenIndicator_icon o-first fa fa-check"/> + </t> + <t t-if="messageSeenIndicator.hasSomeoneSeen"> + <i class="o_MessageSeenIndicator_icon o-second fa fa-check"/> + </t> + </t> + </span> + </t> +</templates> diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js new file mode 100644 index 00000000..fb9c6b8b --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js @@ -0,0 +1,294 @@ +odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator_tests', function (require) { +'use strict'; + +const components = { + MessageSendIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('message_seen_indicator', {}, function () { +QUnit.module('message_seen_indicator_tests.js', { + beforeEach() { + beforeEach(this); + + this.createMessageSeenIndicatorComponent = async ({ message, thread }, otherProps) => { + const props = Object.assign( + { messageLocalId: message.localId, threadLocalId: thread.localId }, + otherProps + ); + await createRootComponent(this, components.MessageSendIndicator, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('rendering when just one has received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + channelId: 1000, + lastFetchedMessage: [['insert', { id: 100 }]], + partnerId: 10, + }, + { + channelId: 1000, + partnerId: 100, + }, + ]]], + messageSeenIndicators: [['insert', { + channelId: 1000, + messageId: 100, + }]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageSeenIndicatorComponent({ message, thread }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "should display only one seen indicator icon" + ); +}); + +QUnit.test('rendering when everyone have received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + channelId: 1000, + lastFetchedMessage: [['insert', { id: 100 }]], + partnerId: 10, + }, + { + channelId: 1000, + lastFetchedMessage: [['insert', { id: 99 }]], + partnerId: 100, + }, + ]]], + messageSeenIndicators: [['insert', { + channelId: 1000, + messageId: 100, + }]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageSeenIndicatorComponent({ message, thread }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "should display only one seen indicator icon" + ); +}); + +QUnit.test('rendering when just one has seen the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + channelId: 1000, + lastFetchedMessage: [['insert', { id: 100 }]], + lastSeenMessage: [['insert', { id: 100 }]], + partnerId: 10, + }, + { + channelId: 1000, + lastFetchedMessage: [['insert', { id: 99 }]], + partnerId: 100, + }, + ]]], + messageSeenIndicators: [['insert', { + channelId: 1000, + messageId: 100, + }]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageSeenIndicatorComponent({ message, thread }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +QUnit.test('rendering when just one has seen & received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + channelId: 1000, + lastFetchedMessage: [['insert', { id: 100 }]], + lastSeenMessage: [['insert', { id: 100 }]], + partnerId: 10, + }, + { + channelId: 1000, + partnerId: 100, + }, + ]]], + messageSeenIndicators: [['insert', { + channelId: 1000, + messageId: 100, + }]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageSeenIndicatorComponent({ message, thread }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +QUnit.test('rendering when just everyone has seen the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + channelId: 1000, + lastFetchedMessage: [['insert', { id: 100 }]], + lastSeenMessage: [['insert', { id: 100 }]], + partnerId: 10, + }, + { + channelId: 1000, + lastFetchedMessage: [['insert', { id: 100 }]], + lastSeenMessage: [['insert', { id: 100 }]], + partnerId: 100, + }, + ]]], + messageSeenIndicators: [['insert', { + channelId: 1000, + messageId: 100, + }]], + }); + const message = this.env.models['mail.message'].insert({ + author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + originThread: [['link', thread]], + }); + await this.createMessageSeenIndicatorComponent({ message, thread }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.hasClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.js b/addons/mail/static/src/components/messaging_menu/messaging_menu.js new file mode 100644 index 00000000..9eb7fd71 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.js @@ -0,0 +1,234 @@ +odoo.define('mail/static/src/components/messaging_menu/messaging_menu.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'), + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const patchMixin = require('web.patchMixin'); + +const { Component } = owl; + +class MessagingMenu extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + /** + * global JS generated ID for this component. Useful to provide a + * custom class to autocomplete input, so that click in an autocomplete + * item is not considered as a click away from messaging menu in mobile. + */ + this.id = _.uniqueId('o_messagingMenu_'); + useShouldUpdateBasedOnProps(); + useStore(props => { + return { + isDeviceMobile: this.env.messaging && this.env.messaging.device.isMobile, + isDiscussOpen: this.env.messaging && this.env.messaging.discuss.isOpen, + isMessagingInitialized: this.env.isMessagingInitialized(), + messagingMenu: this.env.messaging && this.env.messaging.messagingMenu.__state, + }; + }); + + // bind since passed as props + this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this); + this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + this._constructor(...args); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {mail.messaging_menu} + */ + get messagingMenu() { + return this.env.messaging && this.env.messaging.messagingMenu; + } + + /** + * @returns {string} + */ + get mobileNewMessageInputPlaceholder() { + return this.env._t("Search user..."); + } + + /** + * @returns {Object[]} + */ + get tabs() { + return [{ + icon: 'fa fa-envelope', + id: 'all', + label: this.env._t("All"), + }, { + icon: 'fa fa-user', + id: 'chat', + label: this.env._t("Chat"), + }, { + icon: 'fa fa-users', + id: 'channel', + label: this.env._t("Channel"), + }]; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Closes the menu when clicking outside, if appropriate. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + if (!this.env.messaging) { + /** + * Messaging not created, which means essential models like + * messaging menu are not ready, so user interactions are omitted + * during this (short) period of time. + */ + return; + } + // ignore click inside the menu + if (this.el.contains(ev.target)) { + return; + } + // in all other cases: close the messaging menu when clicking outside + this.messagingMenu.close(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDesktopTabButton(ev) { + this.messagingMenu.update({ activeTabId: ev.currentTarget.dataset.tabId }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickNewMessage(ev) { + if (!this.env.messaging.device.isMobile) { + this.env.messaging.chatWindowManager.openNewMessage(); + this.messagingMenu.close(); + } else { + this.messagingMenu.toggleMobileNewMessage(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickToggler(ev) { + // avoid following dummy href + ev.preventDefault(); + if (!this.env.messaging) { + /** + * Messaging not created, which means essential models like + * messaging menu are not ready, so user interactions are omitted + * during this (short) period of time. + */ + return; + } + this.messagingMenu.toggleOpen(); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideMobileNewMessage(ev) { + ev.stopPropagation(); + this.messagingMenu.toggleMobileNewMessage(); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onMobileNewMessageInputSelect(ev, ui) { + this.env.messaging.openChat({ partnerId: ui.item.id }); + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onMobileNewMessageInputSource(req, res) { + const value = _.escape(req.term); + this.env.models['mail.partner'].imSearch({ + callback: partners => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: value, + limit: 10, + }); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.tabId + */ + _onSelectMobileNavbarTab(ev) { + ev.stopPropagation(); + this.messagingMenu.update({ activeTabId: ev.detail.tabId }); + } + +} + +Object.assign(MessagingMenu, { + components, + props: {}, + template: 'mail.MessagingMenu', +}); + +return patchMixin(MessagingMenu); + +}); diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.scss b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss new file mode 100644 index 00000000..e578218a --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss @@ -0,0 +1,143 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessagingMenu_counter { + position: relative; + transform: translate(-5px, -5px); + margin-right: -10px; // "cancel" right padding of systray items +} + +.o_MessagingMenu_dropdownMenu { + display: flex; + flex-flow: column; + padding-top: 0; + padding-bottom: 0; + overflow-y: auto; + /** + * Override from bootstrap .dropdown-menu to fix top alignment with other + * systray menu. + */ + margin-top: map-get($spacers, 0); + + &.o-messaging-not-initialized { + align-items: center; + justify-content: center; + } + + &:not(.o-mobile) { + flex: 0 1 auto; + width: 350px; + min-height: 50px; + max-height: 400px; + z-index: 1100; // on top of chat windows + } + + &.o-mobile { + flex: 1 1 auto; + position: fixed; + top: $o-mail-chat-window-header-height-mobile; + bottom: 0; + left: 0; + right: 0; + width: 100%; + margin: 0; + max-height: none; + } +} + +.o_MessagingMenu_dropdownMenuHeader { + + &:not(.o-mobile) { + display: flex; + flex-shrink: 0; // Forces Safari to not shrink below fit content + } + + &.o-mobile { + display: grid; + grid-template-areas: + "top" + "bottom"; + grid-template-rows: auto auto; + padding: 5px + } +} + +.o_MessagingMenu_dropdownLoadingIcon { + margin-right: 3px; +} + +.o_MessagingMenu_icon { + font-size: larger +} + +.o_MessagingMenu_loading { + font-size: small; + position: absolute; + bottom: 50%; + right: 0; +} + +.o_MessagingMenu_newMessageButton.o-mobile { + grid-area: top; + justify-self: start; +} + +.o_MessagingMenu_mobileNewMessageInput { + grid-area: bottom; + padding: 8px; + margin-top: 10px +} + +.o_MessagingMenu_notificationList.o-mobile { + flex: 1 1 auto; + overflow-y: auto; +} + + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +// Make hightlight more consistent, due to messaging menu looking quite similar to discuss app in mobile +.o_MessagingMenu.o-is-open { + background-color: rgba(black, 0.1); +} + +.o_MessagingMenu_counter { + background-color: $o-enterprise-primary-color; +} + +.o_MessagingMenu_dropdownMenu.o-mobile { + border: 0; +} + +.o_MessagingMenu_dropdownMenuHeader { + border-bottom: 1px solid gray('400'); + z-index: 1; +} + +.o_MessagingMenu_mobileNewMessageInput { + appearance: none; + border: 1px solid gray('400'); + border-radius: 5px; + outline: none; +} + +.o_MessagingMenu_tabButton.o-desktop { + + &.o-active { + font-weight: bold; + } + + &:not(:hover) { + + &:not(.o-active) { + color: gray('500'); + } + } +} + +.o_MessagingMenu_toggler.o-no-notification { + @include o-mail-systray-no-notification-style(); +} diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.xml b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml new file mode 100644 index 00000000..fc779231 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml @@ -0,0 +1,83 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessagingMenu" owl="1"> + <li class="o_MessagingMenu" t-att-class="{ 'o-is-open': messagingMenu ? messagingMenu.isOpen : false, 'o-mobile': env.messaging ? env.messaging.device.isMobile : false }"> + <a class="o_MessagingMenu_toggler" t-att-class="{ 'o-no-notification': messagingMenu ? !messagingMenu.counter : false }" href="#" title="Conversations" role="button" t-att-aria-expanded="messagingMenu and messagingMenu.isOpen ? 'true' : 'false'" aria-haspopup="true" t-on-click="_onClickToggler"> + <i class="o_MessagingMenu_icon fa fa-comments" role="img" aria-label="Messages"/> + <t t-if="!env.isMessagingInitialized()"> + <i class="o_MessagingMenu_loading fa fa-spinner fa-spin"/> + </t> + <t t-elif="messagingMenu.counter > 0"> + <span class="o_MessagingMenu_counter badge badge-pill"> + <t t-esc="messagingMenu.counter"/> + </span> + </t> + </a> + <t t-if="messagingMenu and messagingMenu.isOpen"> + <div class="o_MessagingMenu_dropdownMenu dropdown-menu dropdown-menu-right" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-messaging-not-initialized': !env.messaging.isInitialized }" role="menu"> + <t t-if="!env.messaging.isInitialized"> + <span><i class="o_MessagingMenu_dropdownLoadingIcon fa fa-spinner fa-spin"/>Please wait...</span> + </t> + <t t-else=""> + <div class="o_MessagingMenu_dropdownMenuHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="!env.messaging.device.isMobile"> + <t t-foreach="['all', 'chat', 'channel']" t-as="tabId" t-key="tabId"> + <button class="o_MessagingMenu_tabButton o-desktop btn btn-link" t-att-class="{ 'o-active': messagingMenu.activeTabId === tabId, }" t-on-click="_onClickDesktopTabButton" type="button" role="tab" t-att-data-tab-id="tabId"> + <t t-if="tabId === 'all'">All</t> + <t t-elif="tabId === 'chat'">Chat</t> + <t t-elif="tabId === 'channel'">Channels</t> + </button> + </t> + </t> + <t t-if="env.messaging.device.isMobile"> + <t t-call="mail.MessagingMenu.newMessageButton"/> + </t> + <div class="o-autogrow"/> + <t t-if="!env.messaging.device.isMobile and !discuss.isOpen"> + <t t-call="mail.MessagingMenu.newMessageButton"/> + </t> + <t t-if="env.messaging.device.isMobile and messagingMenu.isMobileNewMessageToggled"> + <AutocompleteInput + class="o_MessagingMenu_mobileNewMessageInput" + customClass="id + '_mobileNewMessageInputAutocomplete'" + isFocusOnMount="true" + placeholder="mobileNewMessageInputPlaceholder" + select="_onMobileNewMessageInputSelect" + source="_onMobileNewMessageInputSource" + t-on-o-hide="_onHideMobileNewMessage" + /> + </t> + </div> + <NotificationList + class="o_MessagingMenu_notificationList" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + filter="messagingMenu.activeTabId" + /> + <t t-if="env.messaging.device.isMobile"> + <MobileMessagingNavbar + class="o_MessagingMenu_mobileNavbar" + activeTabId="messagingMenu.activeTabId" + tabs="tabs" + t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab" + /> + </t> + </t> + </div> + </t> + </li> + </t> + + <t t-name="mail.MessagingMenu.newMessageButton" owl="1"> + <button class="o_MessagingMenu_newMessageButton btn" + t-att-class="{ + 'btn-link': !env.messaging.device.isMobile, + 'btn-secondary': env.messaging.device.isMobile, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickNewMessage" type="button" + > + New message + </button> + </t> + +</templates> diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js new file mode 100644 index 00000000..d049ab7a --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js @@ -0,0 +1,1039 @@ +odoo.define('mail/static/src/components/messaging_menu/messaging_menu_tests.js', function (require) { +'use strict'; + +const { + afterEach, + afterNextRender, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const { makeTestPromise } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('messaging_menu', {}, function () { +QUnit.module('messaging_menu_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + let { discussWidget, env, widget } = await start(Object.assign({}, params, { + data: this.data, + hasMessagingMenu: true, + })); + this.discussWidget = discussWidget; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('[technical] messaging not created then becomes created', async function (assert) { + /** + * Creation of messaging in env is async due to generation of models being + * async. Generation of models is async because it requires parsing of all + * JS modules that contain pieces of model definitions. + * + * Time of having no messaging is very short, almost imperceptible by user + * on UI, but the display should not crash during this critical time period. + */ + assert.expect(2); + + const messagingBeforeCreationDeferred = makeTestPromise(); + await this.start({ + messagingBeforeCreationDeferred, + waitUntilMessagingCondition: 'none', + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu', + "should have messaging menu even when messaging is not yet created" + ); + + // simulate messaging becoming created + messagingBeforeCreationDeferred.resolve(); + await nextAnimationFrame(); + assert.containsOnce( + document.body, + '.o_MessagingMenu', + "should still contain messaging menu after messaging has been created" + ); +}); + +QUnit.test('[technical] no crash on attempting opening messaging menu when messaging not created', async function (assert) { + /** + * Creation of messaging in env is async due to generation of models being + * async. Generation of models is async because it requires parsing of all + * JS modules that contain pieces of model definitions. + * + * Time of having no messaging is very short, almost imperceptible by user + * on UI, but the display should not crash during this critical time period. + * + * Messaging menu is not expected to be open on click because state of + * messaging menu requires messaging being created. + */ + assert.expect(2); + + await this.start({ + messagingBeforeCreationDeferred: new Promise(() => {}), // keep messaging not created + waitUntilMessagingCondition: 'none', + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu', + "should have messaging menu even when messaging is not yet created" + ); + + let error; + try { + document.querySelector('.o_MessagingMenu_toggler').click(); + await nextAnimationFrame(); + } catch (err) { + error = err; + } + assert.notOk( + !!error, + "Should not crash on attempt to open messaging menu when messaging not created" + ); + if (error) { + throw error; + } +}); + +QUnit.test('messaging not initialized', async function (assert) { + assert.expect(2); + + await this.start({ + async mockRPC(route) { + if (route === '/mail/init_messaging') { + // simulate messaging never initialized + return new Promise(resolve => {}); + } + return this._super(...arguments); + }, + waitUntilMessagingCondition: 'created', + }); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu_loading').length, + 1, + "should display loading icon on messaging menu when messaging not yet initialized" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_dropdownMenu').textContent, + "Please wait...", + "should prompt loading when opening messaging menu" + ); +}); + +QUnit.test('messaging becomes initialized', async function (assert) { + assert.expect(2); + + const messagingInitializedProm = makeTestPromise(); + + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await messagingInitializedProm; + } + return _super(); + }, + waitUntilMessagingCondition: 'created', + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + + // simulate messaging becomes initialized + await afterNextRender(() => messagingInitializedProm.resolve()); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu_loading').length, + 0, + "should no longer display loading icon on messaging menu when messaging becomes initialized" + ); + assert.notOk( + document.querySelector('.o_MessagingMenu_dropdownMenu').textContent.includes("Please wait..."), + "should no longer prompt loading when opening messaging menu when messaging becomes initialized" + ); +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(21); + + await this.start(); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu').length, + 1, + "should have messaging menu" + ); + assert.notOk( + document.querySelector('.o_MessagingMenu').classList.contains('show'), + "should not mark messaging menu item as shown by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_toggler`).length, + 1, + "should have clickable element on messaging menu" + ); + assert.notOk( + document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'), + "should not mark messaging menu clickable item as shown by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_icon`).length, + 1, + "should have icon on clickable element in messaging menu" + ); + assert.ok( + document.querySelector(`.o_MessagingMenu_icon`).classList.contains('fa-comments'), + "should have 'comments' icon on clickable element in messaging menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "should not display any messaging menu dropdown by default" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.hasClass( + document.querySelector('.o_MessagingMenu'), + "o-is-open", + "should mark messaging menu as opened" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 1, + "should display messaging menu dropdown after click" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenuHeader`).length, + 1, + "should have dropdown menu header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenuHeader + .o_MessagingMenu_tabButton + `).length, + 3, + "should have 3 tab buttons to filter items in the header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length, + 1, + "1 tab button should be 'All'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length, + 1, + "1 tab button should be 'Chat'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length, + 1, + "1 tab button should be 'Channels'" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should not be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 1, + "should have button to make a new message" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList + `).length, + 1, + "should display thread preview list" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_noConversation + `).length, + 1, + "should display no conversation in thread preview list" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.doesNotHaveClass( + document.querySelector('.o_MessagingMenu'), + "o-is-open", + "should mark messaging menu as closed" + ); +}); + +QUnit.test('counter is taking into account failure notification', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 31, + seen_message_id: 11, + }); + // message that is expected to have a failure + this.data['mail.message'].records.push({ + id: 11, // random unique id, will be used to link failure to message + model: 'mail.channel', // expected value to link message to channel + res_id: 31, // id of a random channel + }); + // failure that is expected to be used in the test + this.data['mail.notification'].records.push({ + mail_message_id: 11, // id of the related message + notification_status: 'exception', // necessary value to have a failure + }); + await this.start(); + + assert.containsOnce( + document.body, + '.o_MessagingMenu_counter', + "should display a notification counter next to the messaging menu for one notification" + ); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_counter').textContent, + "1", + "should display a counter of '1' next to the messaging menu" + ); +}); + +QUnit.test('switch tab', async function (assert) { + assert.expect(15); + + await this.start(); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length, + 1, + "1 tab button should be 'All'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length, + 1, + "1 tab button should be 'Chat'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length, + 1, + "1 tab button should be 'Channels'" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should not be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).click() + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should become inactive" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not become active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should stay inactive" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).click() + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should stay active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should become inactive" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should become active" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).click() + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should become active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should stay inactive" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should become inactive" + ); +}); + +QUnit.test('new message', async function (assert) { + assert.expect(3); + + await this.start({ + hasChatWindow: true, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-new-message'), + "chat window should be for new message" + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'), + "chat window should be focused" + ); +}); + +QUnit.test('no new message when discuss is open', async function (assert) { + assert.expect(3); + + await this.start({ + autoOpenDiscuss: true, + hasDiscuss: true, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 0, + "should not have 'new message' when discuss is open" + ); + + // simulate closing discuss app + await afterNextRender(() => this.discussWidget.on_detach_callback()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 1, + "should have 'new message' when discuss is closed" + ); + + // simulate opening discuss app + await afterNextRender(() => this.discussWidget.on_attach_callback()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 0, + "should not have 'new message' when discuss is open again" + ); +}); + +QUnit.test('channel preview: basic rendering', async function (assert) { + assert.expect(9); + + this.data['res.partner'].records.push({ + id: 7, // random unique id, to link message author + name: "Demo", // random name, will be asserted in the test + }); + // channel that is expected to be found in the test + this.data['mail.channel'].records.push({ + id: 20, // random unique id, will be used to link message to channel + name: "General", // random name, will be asserted in the test + }); + // message that is expected to be displayed in the test + this.data['mail.message'].records.push({ + author_id: 7, // not current partner, will be asserted in the test + body: "<p>test</p>", // random body, will be asserted in the test + channel_ids: [20], // id of related channel + model: 'mail.channel', // necessary to link message to channel + res_id: 20, // id of related channel + }); + await this.start(); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu .o_ThreadPreview + `).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_sidebar + `).length, + 1, + "preview should have a sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_content + `).length, + 1, + "preview should have some content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_header + `).length, + 1, + "preview should have header in content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_header + .o_ThreadPreview_name + `).length, + 1, + "preview should have name in header of content" + ); + assert.strictEqual( + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_name + `).textContent, + "General", "preview should have name of channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_content + .o_ThreadPreview_core + `).length, + 1, + "preview should have core in content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_core + .o_ThreadPreview_inlineText + `).length, + 1, + "preview should have inline text in core of content" + ); + assert.strictEqual( + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_core + .o_ThreadPreview_inlineText + `).textContent.trim(), + "Demo: test", + "preview should have message content as inline text of core content" + ); +}); + +QUnit.test('filtered previews', async function (assert) { + assert.expect(12); + + // chat and channel expected to be found in the menu + this.data['mail.channel'].records.push( + { channel_type: "chat", id: 10 }, + { id: 20 }, + ); + this.data['mail.message'].records.push( + { + channel_ids: [10], // id of related channel + model: 'mail.channel', // to link message to channel + res_id: 10, // id of related channel + }, + { + channel_ids: [20], // id of related channel + model: 'mail.channel', // to link message to channel + res_id: 20, // id of related channel + }, + ); + await this.start(); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 2, + "should have 2 previews" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="chat"]').click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 0, + "should not have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="channel"]').click() + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview + `).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 0, + "should not have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="all"]').click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 2, + "should have 2 previews" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }).localId + }"] + `).length, + 1, + "should have preview of channel" + ); +}); + +QUnit.test('open chat window from preview', async function (assert) { + assert.expect(1); + + // channel expected to be found in the menu, only its existence matters, data are irrelevant + this.data['mail.channel'].records.push({}); + await this.start({ + hasChatWindow: true, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); +}); + +QUnit.test('no code injection in message body preview', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push({ + body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>", + channel_ids: [11], + }); + await this.start(); + + await afterNextRender(() => { + document.querySelector(`.o_MessagingMenu_toggler`).click(); + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu_dropdownMenu .o_ThreadPreview', + "should display a preview", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_core', + "preview should have core in content", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_inlineText', + "preview should have inline text in core of content", + ); + assert.strictEqual( + document.querySelector('.o_ThreadPreview_inlineText') + .textContent.replace(/\s/g, ""), + "You:&shoulnotberaisedthrownewError('CodeInjectionError');", + "should display correct uninjected last message inline content" + ); + assert.containsNone( + document.querySelector('.o_ThreadPreview_inlineText'), + 'script', + "last message inline content should not have any code injection" + ); +}); + +QUnit.test('no code injection in message body preview from sanitized message', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push({ + body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>", + channel_ids: [11], + }); + await this.start(); + + await afterNextRender(() => { + document.querySelector(`.o_MessagingMenu_toggler`).click(); + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu_dropdownMenu .o_ThreadPreview', + "should display a preview", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_core', + "preview should have core in content", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_inlineText', + "preview should have inline text in core of content", + ); + assert.strictEqual( + document.querySelector('.o_ThreadPreview_inlineText') + .textContent.replace(/\s/g, ""), + "You:<em>&shoulnotberaised</em><script>thrownewError('CodeInjectionError');</script>", + "should display correct uninjected last message inline content" + ); + assert.containsNone( + document.querySelector('.o_ThreadPreview_inlineText'), + 'script', + "last message inline content should not have any code injection" + ); +}); + +QUnit.test('<br/> tags in message body preview are transformed in spaces', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push({ + body: "<p>a<br/>b<br>c<br />d<br ></p>", + channel_ids: [11], + }); + await this.start(); + + await afterNextRender(() => { + document.querySelector(`.o_MessagingMenu_toggler`).click(); + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu_dropdownMenu .o_ThreadPreview', + "should display a preview", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_core', + "preview should have core in content", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_inlineText', + "preview should have inline text in core of content", + ); + assert.strictEqual( + document.querySelector('.o_ThreadPreview_inlineText').textContent, + "You: a b c d", + "should display correct last message inline content with brs replaced by spaces" + ); +}); + +QUnit.test('rendering with OdooBot has a request (default)', async function (assert) { + assert.expect(4); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'default', + }, + }, + }, + }); + + assert.ok( + document.querySelector('.o_MessagingMenu_counter'), + "should display a notification counter next to the messaging menu for OdooBot request" + ); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_counter').textContent, + "1", + "should display a counter of '1' next to the messaging menu" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + assert.containsOnce( + document.body, + '.o_NotificationRequest', + "should display a notification in the messaging menu" + ); + assert.strictEqual( + document.querySelector('.o_NotificationRequest_name').textContent.trim(), + 'OdooBot has a request', + "notification should display that OdooBot has a request" + ); +}); + +QUnit.test('rendering without OdooBot has a request (denied)', async function (assert) { + assert.expect(2); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'denied', + }, + }, + }, + }); + + assert.containsNone( + document.body, + '.o_MessagingMenu_counter', + "should not display a notification counter next to the messaging menu" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + assert.containsNone( + document.body, + '.o_NotificationRequest', + "should display no notification in the messaging menu" + ); +}); + +QUnit.test('rendering without OdooBot has a request (accepted)', async function (assert) { + assert.expect(2); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'granted', + }, + }, + }, + }); + + assert.containsNone( + document.body, + '.o_MessagingMenu_counter', + "should not display a notification counter next to the messaging menu" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + assert.containsNone( + document.body, + '.o_NotificationRequest', + "should display no notification in the messaging menu" + ); +}); + +QUnit.test('respond to notification prompt (denied)', async function (assert) { + assert.expect(3); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'default', + async requestPermission() { + this.permission = 'denied'; + return this.permission; + }, + }, + }, + }, + }); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + await afterNextRender(() => + document.querySelector('.o_NotificationRequest').click() + ); + assert.containsOnce( + document.body, + '.toast .o_notification_content', + "should display a toast notification with the deny confirmation" + ); + assert.containsNone( + document.body, + '.o_MessagingMenu_counter', + "should not display a notification counter next to the messaging menu" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_toggler').click() + ); + assert.containsNone( + document.body, + '.o_NotificationRequest', + "should display no notification in the messaging menu" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js new file mode 100644 index 00000000..45b53e87 --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js @@ -0,0 +1,61 @@ +odoo.define('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); + +const { Component } = owl; + +class MobileMessagingNavbar extends Component { + + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + tabs: 2, + }, + }); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + this.trigger('o-select-mobile-messaging-navbar-tab', { + tabId: ev.currentTarget.dataset.tabId, + }); + } + +} + +Object.assign(MobileMessagingNavbar, { + defaultProps: { + tabs: [], + }, + props: { + activeTabId: String, + tabs: { + type: Array, + element: { + type: Object, + shape: { + icon: { + type: String, + optional: true, + }, + id: String, + label: String, + }, + }, + }, + }, + template: 'mail.MobileMessagingNavbar', +}); + +return MobileMessagingNavbar; + +}); diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss new file mode 100644 index 00000000..df0611f9 --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MobileMessagingNavbar { + display: flex; + flex: 0 0 auto; + z-index: 1; +} + +.o_MobileMessagingNavbar_tab { + display: flex; + flex-flow: column; + align-items: center; + flex: 1 1 0; + padding: 8px; +} + +.o_MobileMessagingNavbar_tabIcon { + margin-bottom: 4%; + font-size: 1.3em; +} + +.o_MobileMessagingNavbar_tabLabel { + font-size: 0.8em; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MobileMessagingNavbar { + background-color: white; + box-shadow: 0 0 8px gray('400'); +} + +.o_MobileMessagingNavbar_tab { + box-shadow: 1px 0 0 gray('400'); + + &.o-active { + color: $o-brand-primary; + } +} diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml new file mode 100644 index 00000000..d60611bf --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MobileMessagingNavbar" owl="1"> + <div class="o_MobileMessagingNavbar"> + <t t-foreach="props.tabs" t-as="tab" t-key="tab.id"> + <div class="o_MobileMessagingNavbar_tab" t-att-class="{ 'o-active': props.activeTabId === tab.id }" t-on-click="_onClick" t-att-data-tab-id="tab.id"> + <t t-if="tab.icon"> + <span class="o_MobileMessagingNavbar_tabIcon" t-att-class="tab.icon"/> + </t> + <span class="o_MobileMessagingNavbar_tabLabel"><t t-esc="tab.label"/></span> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js new file mode 100644 index 00000000..c96bd902 --- /dev/null +++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js @@ -0,0 +1,94 @@ +odoo.define('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + Dialog: require('web.OwlDialog'), +}; + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ModerationBanDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + messageLocalIds: 1, + }, + }); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => this.env.models['mail.message'].get(localId)); + } + + /** + * @returns {string} + */ + get CONFIRMATION() { + return this.env._t("Confirmation"); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickBan() { + this._dialogRef.comp._close(); + this.env.models['mail.message'].moderate(this.messages, 'ban'); + } + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + +} + +Object.assign(ModerationBanDialog, { + components, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationBanDialog', +}); + +return ModerationBanDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml new file mode 100644 index 00000000..1e29f731 --- /dev/null +++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationBanDialog" owl="1"> + <Dialog contentClass="'o_ModerationBanDialog'" title="CONFIRMATION" size="'medium'" t-ref="dialog"> + <t t-if="messages.length === 1"> + <p>You are going to ban the following user:</p> + </t> + <t t-else=""> + <p>You are going to ban the following users:</p> + </t> + <ul class="my-5"> + <t t-foreach="messages" t-as="message" t-key="message.localId"> + <li t-esc="message.email_from"/> + </t> + </ul> + <p>Do you confirm the action?</p> + <t t-set-slot="buttons"> + <button class="o-ban btn btn-primary" t-on-click="_onClickBan">Ban</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </t> +</templates> diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js new file mode 100644 index 00000000..4c444683 --- /dev/null +++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js @@ -0,0 +1,109 @@ +odoo.define('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + Dialog: require('web.OwlDialog'), +}; + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ModerationDiscardDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + messageLocalIds: 1, + }, + }); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + getBody() { + if (this.messages.length === 1) { + return this.env._t("You are going to discard 1 message."); + } + return _.str.sprintf( + this.env._t("You are going to discard %s messages."), + this.messages.length + ); + } + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + } + + /** + * @returns {string} + */ + getTitle() { + return this.env._t("Confirmation"); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + + /** + * @private + */ + _onClickDiscard() { + this._dialogRef.comp._close(); + this.env.models['mail.message'].moderate(this.messages, 'discard'); + } + +} + +Object.assign(ModerationDiscardDialog, { + components, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationDiscardDialog', +}); + +return ModerationDiscardDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml new file mode 100644 index 00000000..58dbc14d --- /dev/null +++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationDiscardDialog" owl="1"> + <Dialog contentClass="'o_ModerationDiscardDialog'" title="getTitle()" size="'medium'" t-ref="dialog"> + <p t-esc="getBody()"/> + <p>Do you confirm the action?</p> + <t t-set-slot="buttons"> + <button class="o-discard btn btn-primary" t-on-click="_onClickDiscard">Discard</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </t> +</templates> diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js new file mode 100644 index 00000000..44b82bc6 --- /dev/null +++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js @@ -0,0 +1,104 @@ +odoo.define('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + Dialog: require('web.OwlDialog'), +}; + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +class ModerationRejectDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + messageLocalIds: 1, + }, + }); + this.state = useState({ + title: this.env._t("Message Rejected"), + comment: this.env._t("Your message was rejected by moderator."), + }); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + } + + /** + * @returns {string} + */ + get SEND_EXPLANATION_TO_AUTHOR() { + return this.env._t("Send explanation to author"); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + + /** + * @private + */ + _onClickReject() { + this._dialogRef.comp._close(); + const kwargs = { + title: this.state.title, + comment: this.state.comment, + }; + this.env.models['mail.message'].moderate(this.messages, 'reject', kwargs); + } + +} + +Object.assign(ModerationRejectDialog, { + components, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationRejectDialog', +}); + +return ModerationRejectDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml new file mode 100644 index 00000000..182ffc7e --- /dev/null +++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationRejectDialog" owl="1"> + <Dialog contentClass="'o_ModerationRejectDialog'" title="SEND_EXPLANATION_TO_AUTHOR" size="'medium'" t-ref="dialog"> + <input class="o_ModerationRejectDialog_title form-control" type="text" placeholder="Subject" autofocus="autofocus" t-model="state.title"/> + <textarea class="o_ModerationRejectDialog_comment form-control mt16" placeholder="Mail Body" t-model="state.comment"/> + <t t-set-slot="buttons"> + <button class="o-reject btn btn-primary" t-on-click="_onClickReject">Reject</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </t> +</templates> diff --git a/addons/mail/static/src/components/notification_alert/notification_alert.js b/addons/mail/static/src/components/notification_alert/notification_alert.js new file mode 100644 index 00000000..7ef9e3b1 --- /dev/null +++ b/addons/mail/static/src/components/notification_alert/notification_alert.js @@ -0,0 +1,54 @@ +odoo.define('mail/static/src/components/notification_alert/notification_alert.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class NotificationAlert extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const isMessagingInitialized = this.env.isMessagingInitialized(); + return { + isMessagingInitialized, + isNotificationBlocked: this.isNotificationBlocked, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {boolean} + */ + get isNotificationBlocked() { + if (!this.env.isMessagingInitialized()) { + return false; + } + const windowNotification = this.env.browser.Notification; + return ( + windowNotification && + windowNotification.permission !== "granted" && + !this.env.messaging.isNotificationPermissionDefault() + ); + } + +} + +Object.assign(NotificationAlert, { + props: {}, + template: 'mail.NotificationAlert', +}); + +return NotificationAlert; + +}); diff --git a/addons/mail/static/src/components/notification_alert/notification_alert.xml b/addons/mail/static/src/components/notification_alert/notification_alert.xml new file mode 100644 index 00000000..3d80da13 --- /dev/null +++ b/addons/mail/static/src/components/notification_alert/notification_alert.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationAlert" owl="1"> + <div class="o_NotificationAlert"> + <t t-if="env.isMessagingInitialized()"> + <center t-if="isNotificationBlocked" class="o_notification_alert alert alert-primary"> + Odoo Push notifications have been blocked. Go to your browser settings to allow them. + </center> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/notification_group/notification_group.js b/addons/mail/static/src/components/notification_group/notification_group.js new file mode 100644 index 00000000..17936986 --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.js @@ -0,0 +1,93 @@ +odoo.define('mail/static/src/components/notification_group/notification_group.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class NotificationGroup extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const group = this.env.models['mail.notification_group'].get(props.notificationGroupLocalId); + return { + group: group ? group.__state : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.notification_group} + */ + get group() { + return this.env.models['mail.notification_group'].get(this.props.notificationGroupLocalId); + } + + /** + * @returns {string|undefined} + */ + image() { + if (this.group.notification_type === 'email') { + return '/mail/static/src/img/smiley/mailfailure.jpg'; + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.group.openDocuments(); + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + this.group.openCancelAction(); + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } + } + +} + +Object.assign(NotificationGroup, { + props: { + notificationGroupLocalId: String, + }, + template: 'mail.NotificationGroup', +}); + +return NotificationGroup; + +}); diff --git a/addons/mail/static/src/components/notification_group/notification_group.scss b/addons/mail/static/src/components/notification_group/notification_group.scss new file mode 100644 index 00000000..88a67002 --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.scss @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_NotificationGroup { + @include o-mail-notification-list-item-layout(); + + &:hover .o_NotificationGroup_markAsRead { + // TODO also mixin this + // task-2258605 + opacity: 1; + } +} + +.o_NotificationGroup_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_NotificationGroup_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_NotificationGroup_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_NotificationGroup_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_NotificationGroup_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_NotificationGroup_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_NotificationGroup_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_NotificationGroup_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_NotificationGroup_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_NotificationGroup_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_NotificationGroup_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_NotificationGroup_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_NotificationGroup { + @include o-mail-notification-list-item-style(); +} + +.o_NotificationGroup_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_NotificationGroup_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_NotificationGroup_date { + @include o-mail-notification-list-item-date-style(); +} + +.o_NotificationGroup_image { + @include o-mail-notification-list-item-image-style(); +} + +.o_NotificationGroup_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_NotificationGroup_name { + @include o-mail-notification-list-item-bold-style(); +} diff --git a/addons/mail/static/src/components/notification_group/notification_group.xml b/addons/mail/static/src/components/notification_group/notification_group.xml new file mode 100644 index 00000000..c2f3dceb --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationGroup" owl="1"> + <div class="o_NotificationGroup" t-on-click="_onClick"> + <t t-if="group"> + <div class="o_NotificationGroup_sidebar"> + <div class="o_NotificationGroup_imageContainer o_NotificationGroup_sidebarItem"> + <img class="o_NotificationGroup_image rounded-circle" t-att-src="image()" alt="Message delivery failure image"/> + </div> + </div> + <div class="o_NotificationGroup_content"> + <div class="o_NotificationGroup_header"> + <span class="o_NotificationGroup_name"> + <t t-esc="group.res_model_name"/> + </span> + <span class="o_NotificationGroup_counter"> + (<t t-esc="group.notifications.length"/>) + </span> + <span class="o-autogrow"/> + <span class="o_NotificationGroup_date"> + <t t-esc="group.date.fromNow()"/> + </span> + </div> + <div class="o_NotificationGroup_core"> + <span class="o_NotificationGroup_coreItem o_NotificationGroup_inlineText"> + <t t-if="group.notification_type === 'email'"> + An error occurred when sending an email. + </t> + </span> + <span class="o-autogrow"/> + <span class="o_NotificationGroup_coreItem o_NotificationGroup_markAsRead fa fa-check" title="Discard message delivery failures" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/notification_list/notification_list.js b/addons/mail/static/src/components/notification_list/notification_list.js new file mode 100644 index 00000000..33737ba4 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.js @@ -0,0 +1,226 @@ +odoo.define('mail/static/src/components/notification_list/notification_list.js', function (require) { +'use strict'; + +const components = { + NotificationGroup: require('mail/static/src/components/notification_group/notification_group.js'), + NotificationRequest: require('mail/static/src/components/notification_request/notification_request.js'), + ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'), + ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class NotificationList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + this.storeProps = useStore((...args) => this._useStoreSelector(...args), { + compareDepth: { + // list + notification object created in useStore + notifications: 2, + }, + }); + } + + mounted() { + this._loadPreviews(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Object[]} + */ + get notifications() { + const { notifications } = this.storeProps; + return notifications; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Load previews of given thread. Basically consists of fetching all missing + * last messages of each thread. + * + * @private + */ + async _loadPreviews() { + const threads = this.notifications + .filter(notification => notification.thread && notification.thread.exists()) + .map(notification => notification.thread); + this.env.models['mail.thread'].loadPreviews(threads); + } + + /** + * @private + * @param {Object} props + */ + _useStoreSelector(props) { + const threads = this._useStoreSelectorThreads(props); + let threadNeedactionNotifications = []; + if (props.filter === 'all') { + // threads with needactions + threadNeedactionNotifications = this.env.models['mail.thread'] + .all(t => t.model !== 'mail.box' && t.needactionMessagesAsOriginThread.length > 0) + .sort((t1, t2) => { + if (t1.needactionMessagesAsOriginThread.length > 0 && t2.needactionMessagesAsOriginThread.length === 0) { + return -1; + } + if (t1.needactionMessagesAsOriginThread.length === 0 && t2.needactionMessagesAsOriginThread.length > 0) { + return 1; + } + if (t1.lastNeedactionMessageAsOriginThread && t2.lastNeedactionMessageAsOriginThread) { + return t1.lastNeedactionMessageAsOriginThread.date.isBefore(t2.lastNeedactionMessageAsOriginThread.date) ? 1 : -1; + } + if (t1.lastNeedactionMessageAsOriginThread) { + return -1; + } + if (t2.lastNeedactionMessageAsOriginThread) { + return 1; + } + return t1.id < t2.id ? -1 : 1; + }) + .map(thread => { + return { + thread, + type: 'thread_needaction', + uniqueId: thread.localId + '_needaction', + }; + }); + } + // thread notifications + const threadNotifications = threads + .sort((t1, t2) => { + if (t1.localMessageUnreadCounter > 0 && t2.localMessageUnreadCounter === 0) { + return -1; + } + if (t1.localMessageUnreadCounter === 0 && t2.localMessageUnreadCounter > 0) { + return 1; + } + if (t1.lastMessage && t2.lastMessage) { + return t1.lastMessage.date.isBefore(t2.lastMessage.date) ? 1 : -1; + } + if (t1.lastMessage) { + return -1; + } + if (t2.lastMessage) { + return 1; + } + return t1.id < t2.id ? -1 : 1; + }) + .map(thread => { + return { + thread, + type: 'thread', + uniqueId: thread.localId, + }; + }); + let notifications = threadNeedactionNotifications.concat(threadNotifications); + if (props.filter === 'all') { + const notificationGroups = this.env.messaging.notificationGroupManager.groups; + notifications = Object.values(notificationGroups) + .sort((group1, group2) => + group1.date.isAfter(group2.date) ? -1 : 1 + ).map(notificationGroup => { + return { + notificationGroup, + uniqueId: notificationGroup.localId, + }; + }).concat(notifications); + } + // native notification request + if (props.filter === 'all' && this.env.messaging.isNotificationPermissionDefault()) { + notifications.unshift({ + type: 'odoobotRequest', + uniqueId: 'odoobotRequest', + }); + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + notifications, + }; + } + + /** + * @private + * @param {Object} props + * @throws {Error} in case `props.filter` is not supported + * @returns {mail.thread[]} + */ + _useStoreSelectorThreads(props) { + if (props.filter === 'mailbox') { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } else if (props.filter === 'channel') { + return this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'channel' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else if (props.filter === 'chat') { + return this.env.models['mail.thread'] + .all(thread => + thread.isChatChannel && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else if (props.filter === 'all') { + // "All" filter is for channels and chats + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.channel') + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else { + throw new Error(`Unsupported filter ${props.filter}`); + } + } + +} + +Object.assign(NotificationList, { + _allowedFilters: ['all', 'mailbox', 'channel', 'chat'], + components, + defaultProps: { + filter: 'all', + }, + props: { + filter: { + type: String, + validate: prop => NotificationList._allowedFilters.includes(prop), + }, + }, + template: 'mail.NotificationList', +}); + +return NotificationList; + +}); diff --git a/addons/mail/static/src/components/notification_list/notification_list.scss b/addons/mail/static/src/components/notification_list/notification_list.scss new file mode 100644 index 00000000..18e31149 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.scss @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + + .o_NotificationList { + display: flex; + flex-flow: column; + overflow: auto; + + &.o-empty { + justify-content: center; + } +} + +.o_NotificationList_noConversation { + display: flex; + align-items: center; + justify-content: center; + padding: map-get($spacers, 4) map-get($spacers, 2); +} + +.o_NotificationList_separator { + flex: 0 0 auto; + width: map-get($sizes, 100); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_NotificationList_separator { + border-bottom: $border-width solid $border-color; +} + +.o_NotificationList_noConversation { + color: $text-muted; +} diff --git a/addons/mail/static/src/components/notification_list/notification_list.xml b/addons/mail/static/src/components/notification_list/notification_list.xml new file mode 100644 index 00000000..e3bfbf38 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationList" owl="1"> + <div class="o_NotificationList" t-att-class="{ 'o-empty': notifications.length === 0 }"> + <t t-if="notifications.length === 0"> + <div class="o_NotificationList_noConversation"> + No conversation yet... + </div> + </t> + <t t-else=""> + <t t-foreach="notifications" t-as="notification" t-key="notification.uniqueId"> + <t t-if="notification.type === 'thread' and notification.thread"> + <ThreadPreview + class="o_NotificationList_preview" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + threadLocalId="notification.thread.localId" + /> + </t> + <t t-if="notification.type === 'thread_needaction' and notification.thread"> + <ThreadNeedactionPreview + class="o_NotificationList_preview" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + threadLocalId="notification.thread.localId" + /> + </t> + <t t-if="notification.notificationGroup"> + <NotificationGroup + class="o_NotificationList_group" + notificationGroupLocalId="notification.notificationGroup.localId" + /> + </t> + <t t-if="notification.type === 'odoobotRequest'"> + <NotificationRequest + class="o_NotificationList_notificationRequest" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + /> + </t> + <t t-if="!notification_last"> + <div class="o_NotificationList_separator"/> + </t> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/notification_list/notification_list_item.scss b/addons/mail/static/src/components/notification_list/notification_list_item.scss new file mode 100644 index 00000000..af98a9fb --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_item.scss @@ -0,0 +1,179 @@ +// ----------------------------------------------------------------------------- +// Layout +// ----------------------------------------------------------------------------- + +@mixin o-mail-notification-list-item-layout { + display: flex; + flex: 0 0 auto; // Without this, Safari shrinks parent regardless of child content + align-items: center; + padding: map-get($spacers, 1); + + &.o-mobile { + padding: map-get($spacers, 2); + } +} + +@mixin o-mail-notification-list-item-content-layout { + display: flex; + flex-flow: column; + flex: 1 1 auto; + align-self: flex-start; + min-width: 0; // needed for flex to work correctly + margin: map-get($spacers, 2); +} + +@mixin o-mail-notification-list-item-core-layout { + display: flex; +} + +@mixin o-mail-notification-list-item-core-item-layout { + margin: map-get($spacers, 0) map-get($spacers, 2); + + &:first-child { + margin-inline-start: map-get($spacers, 0); + } + + &:last-child { + margin-inline-end: map-get($spacers, 0); + } +} + +@mixin o-mail-notification-list-item-counter-layout() { + margin: map-get($spacers, 0) map-get($spacers, 2); +} + +@mixin o-mail-notification-list-item-date-layout() { + flex: 0 0 auto; +} + +@mixin o-mail-notification-list-item-header-layout { + display: flex; + margin-bottom: map-get($spacers, 1); +} + +@mixin o-mail-notification-list-item-image-layout { + width: map-get($sizes, 100); + height: map-get($sizes, 100); +} + +@mixin o-mail-notification-list-item-image-container-layout { + position: relative; + width: 40px; + height: 40px; +} + +@mixin o-mail-notification-list-item-inline-text-layout { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.o-empty::before { + content: '\00a0'; // keep line-height as if it had content + } +} + +@mixin o-mail-notification-list-item-mark-as-read-layout() { + display: flex; + flex: 0 0 auto; +} + +@mixin o-mail-notification-list-item-name-layout { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.o-mobile { + font-size: 1.1em; + } +} + +@mixin o-mail-notification-list-item-partner-im-status-icon-layout { + @include o-position-absolute($bottom: 0, $right: 0); + display: flex; + align-items: center; + justify-content: center; +} + +@mixin o-mail-notification-list-item-sidebar-layout { + margin: map-get($spacers, 1); +} + +// ----------------------------------------------------------------------------- +// Style +// ----------------------------------------------------------------------------- + +$o-mail-notification-list-item-background-color: $white !default; +$o-mail-notification-list-item-hover-background-color: + darken($o-mail-notification-list-item-background-color, 7%) !default; + +$o-mail-notification-list-item-muted-background-color: gray('100') !default; +$o-mail-notification-list-item-muted-hover-background-color: + darken($o-mail-notification-list-item-muted-background-color, 7%) !default; + +@mixin o-mail-notification-list-item-style { + cursor: pointer; + user-select: none; + background-color: $o-mail-notification-list-item-background-color; + + &:hover { + background-color: $o-mail-notification-list-item-hover-background-color; + } + + &.o-muted { + background-color: $o-mail-notification-list-item-muted-background-color; + + &:hover { + background-color: $o-mail-notification-list-item-muted-hover-background-color; + } + } +} + +@mixin o-mail-notification-list-item-bold-style { + font-weight: bold; + + &.o-muted { + font-weight: initial; + } +} + +@mixin o-mail-notification-list-item-core-style { + color: gray('500'); +} + +@mixin o-mail-notification-list-item-date-style() { + @include o-mail-notification-list-item-bold-style(); + font-size: x-small; + color: $o-brand-primary; +} + +@mixin o-mail-notification-list-item-image-style { + object-fit: cover; +} + +@mixin o-mail-notification-list-item-mark-as-read-style() { + opacity: 0; + + &:hover { + color: gray('600'); + } +} + +@mixin o-mail-notification-list-item-hover-partner-im-status-icon-style { + color: $o-mail-notification-list-item-hover-background-color; +} + +@mixin o-mail-notification-list-item-muted-hover-partner-im-status-icon-style { + color: $o-mail-notification-list-item-muted-hover-background-color; +} + +@mixin o-mail-notification-list-item-partner-im-status-icon-style { + color: $o-mail-notification-list-item-background-color; + + &:not(.o-mobile) { + font-size: x-small; + } + + &.o-muted { + color: $o-mail-notification-list-item-muted-background-color; + } +} diff --git a/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js new file mode 100644 index 00000000..223ce363 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js @@ -0,0 +1,546 @@ +odoo.define('mail/static/src/components/notification_list/notification_list_notification_group_tests.js', function (require) { +'use strict'; + +const components = { + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; + +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('notification_list', {}, function () { +QUnit.module('notification_list_notification_group_tests.js', { + beforeEach() { + beforeEach(this); + + /** + * @param {Object} param0 + * @param {string} [param0.filter='all'] + */ + this.createNotificationListComponent = async ({ filter = 'all' } = {}) => { + await createRootComponent(this, components.NotificationList, { + props: { filter }, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('notification group basic layout', async function (assert) { + assert.expect(10); + + // message that is expected to have a failure + this.data['mail.message'].records.push({ + id: 11, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'mail.channel', // expected value to link message to channel + res_id: 31, // id of a random channel + res_model_name: "Channel", // random res model name, will be asserted in the test + }); + // failure that is expected to be used in the test + this.data['mail.notification'].records.push({ + mail_message_id: 11, // id of the related message + notification_status: 'exception', // necessary value to have a failure + notification_type: 'email', // expected failure type for email message + }); + await this.start(); + await this.createNotificationListComponent(); + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_name', + "should have 1 group name" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_name').textContent, + "Channel", + "should have model name as group name" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have only 1 notification in the group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_date', + "should have 1 group date" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_date').textContent, + "a few seconds ago", + "should have the group date corresponding to now" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_inlineText', + "should have 1 group text" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_inlineText').textContent.trim(), + "An error occurred when sending an email.", + "should have the group text corresponding to email" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_markAsRead', + "should have 1 mark as read button" + ); +}); + +QUnit.test('mark as read', async function (assert) { + assert.expect(6); + + // message that is expected to have a failure + this.data['mail.message'].records.push({ + id: 11, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'mail.channel', // expected value to link message to channel + res_id: 31, // id of a random channel + res_model_name: "Channel", // random res model name, will be asserted in the test + }); + // failure that is expected to be used in the test + this.data['mail.notification'].records.push({ + mail_message_id: 11, // id of the related message + notification_status: 'exception', // necessary value to have a failure + notification_type: 'email', // expected failure type for email message + }); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'mail.mail_resend_cancel_action', + "action should be the one to cancel email" + ); + assert.strictEqual( + payload.options.additional_context.default_model, + 'mail.channel', + "action should have the group model as default_model" + ); + assert.strictEqual( + payload.options.additional_context.unread_counter, + 1, + "action should have the group notification length as unread_counter" + ); + }); + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + assert.containsOnce( + document.body, + '.o_NotificationGroup_markAsRead', + "should have 1 mark as read button" + ); + + document.querySelector('.o_NotificationGroup_markAsRead').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the cancel email dialog" + ); +}); + +QUnit.test('grouped notifications by document', async function (assert) { + // If some failures linked to a document refers to a same document, a single + // notification should group all those failures. + assert.expect(5); + + this.data['mail.message'].records.push( + // first message that is expected to have a failure + { + id: 11, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'res.partner', // same model as second message (and not `mail.channel`) + res_id: 31, // same res_id as second message + res_model_name: "Partner", // random related model name + }, + // second message that is expected to have a failure + { + id: 12, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'res.partner', // same model as first message (and not `mail.channel`) + res_id: 31, // same res_id as first message + res_model_name: "Partner", // same related model name for consistency + } + ); + this.data['mail.notification'].records.push( + // first failure that is expected to be used in the test + { + mail_message_id: 11, // id of the related first message + notification_status: 'exception', // one possible value to have a failure + notification_type: 'email', // expected failure type for email message + }, + // second failure that is expected to be used in the test + { + mail_message_id: 12, // id of the related second message + notification_status: 'bounce', // other possible value to have a failure + notification_type: 'email', // expected failure type for email message + } + ); + await this.start({ hasChatWindow: true }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(2)", + "should have 2 notifications in the group" + ); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should have no chat window initially" + ); + + await afterNextRender(() => + document.querySelector('.o_NotificationGroup').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have opened the thread in a chat window after clicking on it" + ); +}); + +QUnit.test('grouped notifications by document model', async function (assert) { + // If all failures linked to a document model refers to different documents, + // a single notification should group all failures that are linked to this + // document model. + assert.expect(12); + + this.data['mail.message'].records.push( + // first message that is expected to have a failure + { + id: 11, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'res.partner', // same model as second message (and not `mail.channel`) + res_id: 31, // different res_id from second message + res_model_name: "Partner", // random related model name + }, + // second message that is expected to have a failure + { + id: 12, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'res.partner', // same model as first message (and not `mail.channel`) + res_id: 32, // different res_id from first message + res_model_name: "Partner", // same related model name for consistency + } + ); + this.data['mail.notification'].records.push( + // first failure that is expected to be used in the test + { + mail_message_id: 11, // id of the related first message + notification_status: 'exception', // one possible value to have a failure + notification_type: 'email', // expected failure type for email message + }, + // second failure that is expected to be used in the test + { + mail_message_id: 12, // id of the related second message + notification_status: 'bounce', // other possible value to have a failure + notification_type: 'email', // expected failure type for email message + } + ); + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.name, + "Mail Failures", + "action should have 'Mail Failures' as name", + ); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should have the type act_window" + ); + assert.strictEqual( + payload.action.view_mode, + 'kanban,list,form', + "action should have 'kanban,list,form' as view_mode" + ); + assert.strictEqual( + JSON.stringify(payload.action.views), + JSON.stringify([[false, 'kanban'], [false, 'list'], [false, 'form']]), + "action should have correct views" + ); + assert.strictEqual( + payload.action.target, + 'current', + "action should have 'current' as target" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "action should have the group model as res_model" + ); + assert.strictEqual( + JSON.stringify(payload.action.domain), + JSON.stringify([['message_has_error', '=', true]]), + "action should have 'message_has_error' as domain" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(2)", + "should have 2 notifications in the group" + ); + + document.querySelector('.o_NotificationGroup').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the related records" + ); +}); + +QUnit.test('different mail.channel are not grouped', async function (assert) { + // `mail.channel` is a special case where notifications are not grouped when + // they are linked to different channels, even though the model is the same. + assert.expect(6); + + this.data['mail.channel'].records.push({ id: 31 }, { id: 32 }); + this.data['mail.message'].records.push( + // first message that is expected to have a failure + { + id: 11, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'mail.channel', // testing a channel is the goal of the test + res_id: 31, // different res_id from second message + res_model_name: "Channel", // random related model name + }, + // second message that is expected to have a failure + { + id: 12, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'mail.channel', // testing a channel is the goal of the test + res_id: 32, // different res_id from first message + res_model_name: "Channel", // same related model name for consistency + } + ); + this.data['mail.notification'].records.push( + // first failure that is expected to be used in the test + { + mail_message_id: 11, // id of the related first message + notification_status: 'exception', // one possible value to have a failure + notification_type: 'email', // expected failure type for email message + }, + // second failure that is expected to be used in the test + { + mail_message_id: 12, // id of the related second message + notification_status: 'bounce', // other possible value to have a failure + notification_type: 'email', // expected failure type for email message + } + ); + await this.start({ + hasChatWindow: true, // needed to assert thread.open + }); + await this.createNotificationListComponent(); + assert.containsN( + document.body, + '.o_NotificationGroup', + 2, + "should have 2 notifications group" + ); + const groups = document.querySelectorAll('.o_NotificationGroup'); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_counter', + "should have 1 group counter in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in first group" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_counter', + "should have 1 group counter in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in second group" + ); + + await afterNextRender(() => groups[0].click()); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have opened the channel related to the first group in a chat window" + ); +}); + +QUnit.test('multiple grouped notifications by document model, sorted by date desc', async function (assert) { + assert.expect(9); + + this.data['mail.message'].records.push( + // first message that is expected to have a failure + { + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), // random date + id: 11, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'res.partner', // different model from second message + res_id: 31, // random unique id, useful to link failure to message + res_model_name: "Partner", // random related model name + }, + // second message that is expected to have a failure + { + // random date, later than first message + date: moment.utc().add(1, 'days').format("YYYY-MM-DD HH:mm:ss"), + id: 12, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'res.company', // different model from first message + res_id: 32, // random unique id, useful to link failure to message + res_model_name: "Company", // random related model name + } + ); + this.data['mail.notification'].records.push( + // first failure that is expected to be used in the test + { + mail_message_id: 11, // id of the related first message + notification_status: 'exception', // one possible value to have a failure + notification_type: 'email', // expected failure type for email message + }, + // second failure that is expected to be used in the test + { + mail_message_id: 12, // id of the related second message + notification_status: 'bounce', // other possible value to have a failure + notification_type: 'email', // expected failure type for email message + } + ); + await this.start(); + await this.createNotificationListComponent(); + assert.containsN( + document.body, + '.o_NotificationGroup', + 2, + "should have 2 notifications group" + ); + const groups = document.querySelectorAll('.o_NotificationGroup'); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_name', + "should have 1 group name in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_name').textContent, + "Company", + "should have first model name as group name" + ); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_counter', + "should have 1 group counter in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in first group" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_name', + "should have 1 group name in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_name').textContent, + "Partner", + "should have second model name as group name" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_counter', + "should have 1 group counter in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in second group" + ); +}); + +QUnit.test('non-failure notifications are ignored', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push( + // message that is expected to have a notification + { + id: 11, // random unique id, will be used to link failure to message + message_type: 'email', // message must be email (goal of the test) + model: 'res.partner', // random model + res_id: 31, // random unique id, useful to link failure to message + } + ); + this.data['mail.notification'].records.push( + // notification that is expected to be used in the test + { + mail_message_id: 11, // id of the related first message + notification_status: 'ready', // non-failure status + notification_type: 'email', // expected notification type for email message + }, + ); + await this.start(); + await this.createNotificationListComponent(); + assert.containsNone( + document.body, + '.o_NotificationGroup', + "should have 0 notification group" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/notification_list/notification_list_tests.js b/addons/mail/static/src/components/notification_list/notification_list_tests.js new file mode 100644 index 00000000..24df5b22 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_tests.js @@ -0,0 +1,162 @@ +odoo.define('mail/static/src/components/notification_list/notification_list_tests.js', function (require) { +'use strict'; + +const components = { + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; + +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('notification_list', {}, function () { +QUnit.module('notification_list_tests.js', { + beforeEach() { + beforeEach(this); + + /** + * @param {Object} param0 + * @param {string} [param0.filter='all'] + */ + this.createNotificationListComponent = async ({ filter = 'all' }) => { + await createRootComponent(this, components.NotificationList, { + props: { filter }, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('marked as read thread notifications are ordered by last message date', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push( + { id: 100, name: "Channel 2019" }, + { id: 200, name: "Channel 2020" } + ); + this.data['mail.message'].records.push( + { + channel_ids: [100], + date: "2019-01-01 00:00:00", + id: 42, + model: 'mail.channel', + res_id: 100, + }, + { + channel_ids: [200], + date: "2020-01-01 00:00:00", + id: 43, + model: 'mail.channel', + res_id: 200, + } + ); + await this.start(); + await this.createNotificationListComponent({ filter: 'all' }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should be two thread previews" + ); + const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview'); + assert.strictEqual( + threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2020', + "First channel in the list should be the channel of 2020 (more recent last message)" + ); + assert.strictEqual( + threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2019', + "Second channel in the list should be the channel of 2019 (least recent last message)" + ); +}); + +QUnit.test('thread notifications are re-ordered on receiving a new message', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push( + { id: 100, name: "Channel 2019" }, + { id: 200, name: "Channel 2020" } + ); + this.data['mail.message'].records.push( + { + channel_ids: [100], + date: "2019-01-01 00:00:00", + id: 42, + model: 'mail.channel', + res_id: 100, + }, + { + channel_ids: [200], + date: "2020-01-01 00:00:00", + id: 43, + model: 'mail.channel', + res_id: 200, + } + ); + await this.start(); + await this.createNotificationListComponent({ filter: 'all' }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should be two thread previews" + ); + + await afterNextRender(() => { + const messageData = { + author_id: [7, "Demo User"], + body: "<p>New message !</p>", + channel_ids: [100], + date: "2020-03-23 10:00:00", + id: 44, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2019', + res_id: 100, + }; + this.widget.call('bus_service', 'trigger', 'notification', [ + [['my-db', 'mail.channel', 100], messageData] + ]); + }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should still be two thread previews" + ); + const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview'); + assert.strictEqual( + threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2019', + "First channel in the list should now be 'Channel 2019'" + ); + assert.strictEqual( + threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2020', + "Second channel in the list should now be 'Channel 2020'" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.js b/addons/mail/static/src/components/notification_popover/notification_popover.js new file mode 100644 index 00000000..6be3647e --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.js @@ -0,0 +1,95 @@ +odoo.define('mail/static/src/components/notification_popover/notification_popover.js', function (require) { +'use strict'; + +const { Component } = owl; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +class NotificationPopover extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + notificationLocalIds: 1, + }, + }); + useStore(props => { + const notifications = props.notificationLocalIds.map( + notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId) + ); + return { + notifications: notifications.map(notification => notification ? notification.__state : undefined), + }; + }, { + compareDepth: { + notifications: 1, + }, + }); + } + + /** + * @returns {string} + */ + get iconClass() { + switch (this.notification.notification_status) { + case 'sent': + return 'fa fa-check'; + case 'bounce': + return 'fa fa-exclamation'; + case 'exception': + return 'fa fa-exclamation'; + case 'ready': + return 'fa fa-send-o'; + case 'canceled': + return 'fa fa-trash-o'; + } + return ''; + } + + /** + * @returns {string} + */ + get iconTitle() { + switch (this.notification.notification_status) { + case 'sent': + return this.env._t("Sent"); + case 'bounce': + return this.env._t("Bounced"); + case 'exception': + return this.env._t("Error"); + case 'ready': + return this.env._t("Ready"); + case 'canceled': + return this.env._t("Canceled"); + } + return ''; + } + + /** + * @returns {mail.notification[]} + */ + get notifications() { + return this.props.notificationLocalIds.map( + notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId) + ); + } + +} + +Object.assign(NotificationPopover, { + props: { + notificationLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.NotificationPopover', +}); + +return NotificationPopover; + +}); diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.scss b/addons/mail/static/src/components/notification_popover/notification_popover.scss new file mode 100644 index 00000000..06b4201c --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.scss @@ -0,0 +1,7 @@ +// ----------------------------------------------------------------------------- +// Layout +// ----------------------------------------------------------------------------- + +.o_NotificationPopover_notificationIcon { + margin-inline-end: map-get($spacers, 2); +} diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.xml b/addons/mail/static/src/components/notification_popover/notification_popover.xml new file mode 100644 index 00000000..cf5aa027 --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationPopover" owl="1"> + <div class="o_NotificationPopover"> + <t t-foreach="notifications" t-as="notification" t-key="notification.localId"> + <div class="o_NotificationPopover_notification"> + <i class="o_NotificationPopover_notificationIcon" t-att-class="iconClass" t-att-title="iconTitle" role="img"/> + <t t-if="notification.partner"> + <span class="o_NotificationPopover_notificationPartnerName" t-esc="notification.partner.nameOrDisplayName"/> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/notification_request/notification_request.js b/addons/mail/static/src/components/notification_request/notification_request.js new file mode 100644 index 00000000..54dcbbd4 --- /dev/null +++ b/addons/mail/static/src/components/notification_request/notification_request.js @@ -0,0 +1,94 @@ +odoo.define('mail/static/src/components/notification_request/notification_request.js', function (require) { +'use strict'; + +const components = { + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class NotificationRequest extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + return { + isDeviceMobile: this.env.messaging.device.isMobile, + partnerRoot: this.env.messaging.partnerRoot + ? this.env.messaging.partnerRoot.__state + : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + getHeaderText() { + return _.str.sprintf( + this.env._t("%s has a request"), + this.env.messaging.partnerRoot.nameOrDisplayName + ); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Handle the response of the user when prompted whether push notifications + * are granted or denied. + * + * @private + * @param {string} value + */ + _handleResponseNotificationPermission(value) { + // manually force recompute because the permission is not in the store + this.env.messaging.messagingMenu.update(); + if (value !== 'granted') { + this.env.services['bus_service'].sendNotification( + this.env._t("Permission denied"), + this.env._t("Odoo will not have the permission to send native notifications on this device.") + ); + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClick() { + const windowNotification = this.env.browser.Notification; + const def = windowNotification && windowNotification.requestPermission(); + if (def) { + def.then(this._handleResponseNotificationPermission.bind(this)); + } + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } + } + +} + +Object.assign(NotificationRequest, { + components, + props: {}, + template: 'mail.NotificationRequest', +}); + +return NotificationRequest; + +}); diff --git a/addons/mail/static/src/components/notification_request/notification_request.scss b/addons/mail/static/src/components/notification_request/notification_request.scss new file mode 100644 index 00000000..e2fcb81d --- /dev/null +++ b/addons/mail/static/src/components/notification_request/notification_request.scss @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_NotificationRequest { + @include o-mail-notification-list-item-layout(); +} + +.o_NotificationRequest_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_NotificationRequest_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_NotificationRequest_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_NotificationRequest_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_NotificationRequest_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_NotificationRequest_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_NotificationRequest_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_NotificationRequest_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_NotificationRequest_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-layout(); +} + +.o_NotificationRequest_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_NotificationRequest { + @include o-mail-notification-list-item-style(); + + &:hover { + .o_NotificationRequest_partnerImStatusIcon { + @include o-mail-notification-list-item-hover-partner-im-status-icon-style(); + } + } +} + +.o_NotificationRequest_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_NotificationRequest_image { + @include o-mail-notification-list-item-image-style(); +} + +.o_NotificationRequest_name { + @include o-mail-notification-list-item-bold-style(); +} + +.o_NotificationRequest_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-style(); +} diff --git a/addons/mail/static/src/components/notification_request/notification_request.xml b/addons/mail/static/src/components/notification_request/notification_request.xml new file mode 100644 index 00000000..f59c671a --- /dev/null +++ b/addons/mail/static/src/components/notification_request/notification_request.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationRequest" owl="1"> + <div class="o_NotificationRequest" t-on-click="_onClick"> + <div class="o_NotificationRequest_sidebar"> + <div class="o_NotificationRequest_imageContainer o_NotificationRequest_sidebarItem"> + <img class="o_NotificationRequest_image rounded-circle" src="/mail/static/src/img/odoobot.png" alt="Avatar of OdooBot"/> + <PartnerImStatusIcon + class="o_NotificationRequest_partnerImStatusIcon" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + partnerLocalId="env.messaging.partnerRoot.localId" + /> + </div> + </div> + <div class="o_NotificationRequest_content"> + <div class="o_NotificationRequest_header"> + <span class="o_NotificationRequest_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-esc="getHeaderText()"/> + </span> + </div> + <div class="o_NotificationRequest_core"> + <span class="o_NotificationRequest_coreItem o_NotificationRequest_inlineText"> + Enable desktop notifications to chat. + </span> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js new file mode 100644 index 00000000..e4af9da6 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js @@ -0,0 +1,74 @@ +odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class PartnerImStatusIcon extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const partner = this.env.models['mail.partner'].get(props.partnerLocalId); + return { + partner, + partnerImStatus: partner && partner.im_status, + partnerRoot: this.env.messaging.partnerRoot, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.partner} + */ + get partner() { + return this.env.models['mail.partner'].get(this.props.partnerLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (!this.props.hasOpenChat) { + return; + } + this.partner.openChat(); + } + +} + +Object.assign(PartnerImStatusIcon, { + defaultProps: { + hasBackground: true, + hasOpenChat: false, + }, + props: { + partnerLocalId: String, + hasBackground: Boolean, + /** + * Determines whether a click on `this` should open a chat with + * `this.partner`. + */ + hasOpenChat: Boolean, + }, + template: 'mail.PartnerImStatusIcon', +}); + +return PartnerImStatusIcon; + +}); diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss new file mode 100644 index 00000000..608c281a --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss @@ -0,0 +1,59 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_PartnerImStatusIcon { + display: flex; + flex-flow: column; + + width: 1.2em; + height: 1.2em; + line-height: 1.3em; +} + +.o_PartnerImStatusIcon_outerBackground { + transform: scale(1.5); +} + +.o-background { + transform: scale(1); + margin-inline-end: map-get($spacers, 1); + margin-top: 2px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_PartnerImStatusIcon { + &.o-has-open-chat { + cursor: pointer; + } +} + +.o_PartnerImStatusIcon_innerBackground { + color: white; +} + +.o_PartnerImStatusIcon_icon { + + &.o-away { + color: theme-color('warning'); + } + + &.o-bot { + color: $o-enterprise-primary-color; + } + + &.o-offline { + color: gray('700'); + } + + &.o-online { + color: $o-enterprise-primary-color; + } +} + + + + diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml new file mode 100644 index 00000000..ca20a547 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.PartnerImStatusIcon" owl="1"> + <span class="o_PartnerImStatusIcon fa-stack" + t-att-class="{ + 'o-away': partner and partner.im_status === 'away', + 'o-background': !props.hasBackground, + 'o-bot': partner and env.messaging.partnerRoot === partner, + 'o-has-open-chat': props.hasOpenChat, + 'o-offline': partner and partner.im_status === 'offline', + 'o-online': partner and partner.im_status === 'online', + }" + t-on-click="_onClick" + t-att-data-partner-local-id="partner ? partner.localId : undefined" + > + <t t-if="partner" name="rootCondition"> + <t t-if="props.hasBackground"> + <i class="o_PartnerImStatusIcon_outerBackground fa fa-circle fa-stack-1x"/> + <i class="o_PartnerImStatusIcon_innerBackground fa fa-circle fa-stack-1x"/> + </t> + <t t-if="partner.im_status === 'online'"> + <i class="o_PartnerImStatusIcon_icon o-online fa fa-circle fa-stack-1x" title="Online" role="img" aria-label="User is online"/> + </t> + <t t-if="partner.im_status === 'away'"> + <i class="o_PartnerImStatusIcon_icon o-away fa fa-circle fa-stack-1x" title="Idle" role="img" aria-label="User is idle"/> + </t> + <t t-if="partner.im_status === 'offline'"> + <i class="o_PartnerImStatusIcon_icon o-offline fa fa-circle-o fa-stack-1x" title="Offline" role="img" aria-label="User is offline"/> + </t> + <t t-if="partner === env.messaging.partnerRoot"> + <i class="o_PartnerImStatusIcon_icon o-bot fa fa-heart fa-stack-1x" title="Bot" role="img" aria-label="User is a bot"/> + </t> + </t> + </span> + </t> + +</templates> diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js new file mode 100644 index 00000000..1a68a5e0 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js @@ -0,0 +1,145 @@ +odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js', function (require) { +'use strict'; + +const components = { + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('partner_im_status_icon', {}, function () { +QUnit.module('partner_im_status_icon_tests.js', { + beforeEach() { + beforeEach(this); + + this.createPartnerImStatusIcon = async partner => { + await createRootComponent(this, components.PartnerImStatusIcon, { + props: { partnerLocalId: partner.localId }, + target: this.widget.el + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('initially online', async function (assert) { + assert.expect(3); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'online', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon`).length, + 1, + "should have partner IM status icon" + ); + assert.strictEqual( + document.querySelector(`.o_PartnerImStatusIcon`).dataset.partnerLocalId, + partner.localId, + "partner IM status icon should be linked to partner with ID 7" + ); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering" + ); +}); + +QUnit.test('initially offline', async function (assert) { + assert.expect(1); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'offline', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length, + 1, + "partner IM status icon should have offline status rendering" + ); +}); + +QUnit.test('initially away', async function (assert) { + assert.expect(1); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'away', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length, + 1, + "partner IM status icon should have away status rendering" + ); +}); + +QUnit.test('change icon on change partner im_status', async function (assert) { + assert.expect(4); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'online', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'offline' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length, + 1, + "partner IM status icon should have offline status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'away' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length, + 1, + "partner IM status icon should have away status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'online' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering in the end" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.js b/addons/mail/static/src/components/thread_icon/thread_icon.js new file mode 100644 index 00000000..71017ec0 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.js @@ -0,0 +1,64 @@ +odoo.define('mail/static/src/components/thread_icon/thread_icon.js', function (require) { +'use strict'; + +const components = { + ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ThreadIcon extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const correspondent = thread ? thread.correspondent : undefined; + return { + correspondent, + correspondentImStatus: correspondent && correspondent.im_status, + history: this.env.messaging.history, + inbox: this.env.messaging.inbox, + moderation: this.env.messaging.moderation, + partnerRoot: this.env.messaging.partnerRoot, + starred: this.env.messaging.starred, + thread, + threadChannelType: thread && thread.channel_type, + threadModel: thread && thread.model, + threadOrderedOtherTypingMembersLength: thread && thread.orderedOtherTypingMembers.length, + threadPublic: thread && thread.public, + threadTypingStatusText: thread && thread.typingStatusText, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(ThreadIcon, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadIcon', +}); + +return ThreadIcon; + +}); diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.scss b/addons/mail/static/src/components/thread_icon/thread_icon.scss new file mode 100644 index 00000000..3824ec44 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.scss @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadIcon { + display: flex; + width: 13px; + justify-content: center; + flex: 0 0 auto; +} + +.o_ThreadIcon_typing { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadIcon_away { + color: theme-color('warning'); +} + +.o_ThreadIcon_online { + color: $o-enterprise-primary-color; +} diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.xml b/addons/mail/static/src/components/thread_icon/thread_icon.xml new file mode 100644 index 00000000..95c4694a --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadIcon" owl="1"> + <div class="o_ThreadIcon"> + <t t-if="thread" name="rootCondition"> + <t t-if="thread.channel_type === 'channel'"> + <t t-if="thread.public === 'private'"> + <!-- AKU TODO: channel of type 'groups' should maybe also have lock icon --> + <div class="o_ThreadIcon_channelPrivate fa fa-lock" title="Private channel"/> + </t> + <t t-else=""> + <div class="o_ThreadIcon_channelPublic fa fa-hashtag" title="Public channel"/> + </t> + </t> + <t t-elif="thread.channel_type === 'chat' and thread.correspondent"> + <t t-if="thread.orderedOtherTypingMembers.length > 0"> + <ThreadTypingIcon + class="o_ThreadIcon_typing" + animation="'pulse'" + title="thread.typingStatusText" + /> + </t> + <t t-elif="thread.correspondent.im_status === 'online'"> + <div class="o_ThreadIcon_online fa fa-circle" title="Online"/> + </t> + <t t-elif="thread.correspondent.im_status === 'offline'"> + <div class="o_ThreadIcon_offline fa fa-circle-o" title="Offline"/> + </t> + <t t-elif="thread.correspondent.im_status === 'away'"> + <div class="o_ThreadIcon_away fa fa-circle" title="Away"/> + </t> + <t t-elif="thread.correspondent === env.messaging.partnerRoot"> + <div class="o_ThreadIcon_online fa fa-heart" title="Bot"/> + </t> + <t t-else="" name="noImStatusCondition"> + <div class="o_ThreadIcon_noImStatus fa fa-question-circle" title="No IM status available"/> + </t> + </t> + <t t-elif="thread.model === 'mail.box'"> + <t t-if="thread === env.messaging.inbox"> + <div class="o_ThreadIcon_mailboxInbox fa fa-inbox"/> + </t> + <t t-elif="thread === env.messaging.starred"> + <div class="o_ThreadIcon_mailboxStarred fa fa-star-o"/> + </t> + <t t-elif="thread === env.messaging.history"> + <div class="o_ThreadIcon_mailboxHistory fa fa-history"/> + </t> + <t t-elif="thread === env.messaging.moderation"> + <div class="o_ThreadIcon_mailboxModeration fa fa-envelope"/> + </t> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_icon/thread_icon_tests.js b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js new file mode 100644 index 00000000..d233d6f8 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js @@ -0,0 +1,118 @@ +odoo.define('mail/static/src/components/thread_icon/thread_icon_tests.js', function (require) { +'use strict'; + +const components = { + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_icon', {}, function () { +QUnit.module('thread_icon_tests.js', { + beforeEach() { + beforeEach(this); + + this.createThreadIcon = async thread => { + await createRootComponent(this, components.ThreadIcon, { + props: { threadLocalId: thread.localId }, + target: this.widget.el + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('chat: correspondent is typing', async function (assert) { + assert.expect(5); + + this.data['res.partner'].records.push({ + id: 17, + im_status: 'online', + name: 'Demo', + }); + this.data['mail.channel'].records.push({ + channel_type: 'chat', + id: 20, + members: [this.data.currentPartnerId, 17], + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createThreadIcon(thread); + + assert.containsOnce( + document.body, + '.o_ThreadIcon', + "should have thread icon" + ); + assert.containsOnce( + document.body, + '.o_ThreadIcon_online', + "should have thread icon with partner im status icon 'online'" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 17, + partner_name: "Demo", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.containsOnce( + document.body, + '.o_ThreadIcon_typing', + "should have thread icon with partner currently typing" + ); + assert.strictEqual( + document.querySelector('.o_ThreadIcon_typing').title, + "Demo is typing...", + "title of icon should tell demo is currently typing" + ); + + // simulate receive typing notification from demo "no longer is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: false, + partner_id: 17, + partner_name: "Demo", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.containsOnce( + document.body, + '.o_ThreadIcon_online', + "should have thread icon with partner im status icon 'online' (no longer typing)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js new file mode 100644 index 00000000..b70c8f6b --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js @@ -0,0 +1,151 @@ +odoo.define('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js', function (require) { +'use strict'; + +const components = { + MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const mailUtils = require('mail.utils'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadNeedactionPreview extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const mainThreadCache = thread ? thread.mainCache : undefined; + let lastNeedactionMessageAsOriginThreadAuthor; + let lastNeedactionMessageAsOriginThread; + let threadCorrespondent; + if (thread) { + lastNeedactionMessageAsOriginThread = mainThreadCache.lastNeedactionMessageAsOriginThread; + threadCorrespondent = thread.correspondent; + } + if (lastNeedactionMessageAsOriginThread) { + lastNeedactionMessageAsOriginThreadAuthor = lastNeedactionMessageAsOriginThread.author; + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + lastNeedactionMessageAsOriginThread: lastNeedactionMessageAsOriginThread ? lastNeedactionMessageAsOriginThread.__state : undefined, + lastNeedactionMessageAsOriginThreadAuthor: lastNeedactionMessageAsOriginThreadAuthor + ? lastNeedactionMessageAsOriginThreadAuthor.__state + : undefined, + thread: thread ? thread.__state : undefined, + threadCorrespondent: threadCorrespondent + ? threadCorrespondent.__state + : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the image route of the thread. + * + * @returns {string} + */ + image() { + if (this.thread.moduleIcon) { + return this.thread.moduleIcon; + } + if (this.thread.correspondent) { + return this.thread.correspondent.avatarUrl; + } + if (this.thread.model === 'mail.channel') { + return `/web/image/mail.channel/${this.thread.id}/image_128`; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + } + + /** + * Get inline content of the last message of this conversation. + * + * @returns {string} + */ + get inlineLastNeedactionMessageBody() { + if (!this.thread.lastNeedactionMessage) { + return ''; + } + return mailUtils.htmlToTextContentInline(this.thread.lastNeedactionMessage.prettyBody); + } + + /** + * Get inline content of the last message of this conversation. + * + * @returns {string} + */ + get inlineLastNeedactionMessageAsOriginThreadBody() { + if (!this.thread.lastNeedactionMessageAsOriginThread) { + return ''; + } + return mailUtils.htmlToTextContentInline(this.thread.lastNeedactionMessageAsOriginThread.prettyBody); + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.thread.open(); + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + this.env.models['mail.message'].markAllAsRead([ + ['model', '=', this.thread.model], + ['res_id', '=', this.thread.id], + ]); + } + +} + +Object.assign(ThreadNeedactionPreview, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadNeedactionPreview', +}); + +return ThreadNeedactionPreview; + +}); diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss new file mode 100644 index 00000000..5de87f8b --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss @@ -0,0 +1,108 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadNeedactionPreview { + @include o-mail-notification-list-item-layout(); + + &:hover .o_ThreadNeedactionPreview_markAsRead { + opacity: 1; + } +} + +.o_ThreadNeedactionPreview_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_ThreadNeedactionPreview_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_ThreadNeedactionPreview_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_ThreadNeedactionPreview_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_ThreadNeedactionPreview_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_ThreadNeedactionPreview_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_ThreadNeedactionPreview_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_ThreadNeedactionPreview_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_ThreadNeedactionPreview_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_ThreadNeedactionPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_ThreadNeedactionPreview_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-layout(); +} + +.o_ThreadNeedactionPreview_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadNeedactionPreview { + @include o-mail-notification-list-item-style(); + background-color: rgba($o-brand-primary, 0.1); + + &:hover { + background-color: rgba($o-brand-primary, 0.2); + + .o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-hover-partner-im-status-icon-style(); + } + } +} + +.o_ThreadNeedactionPreview_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_ThreadNeedactionPreview_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadNeedactionPreview_date { + @include o-mail-notification-list-item-date-style(); +} + +.o_ThreadNeedactionPreview_image { + @include o-mail-notification-list-item-image-style(); +} + +.o_ThreadNeedactionPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_ThreadNeedactionPreview_name { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-style(); +} diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml new file mode 100644 index 00000000..3fd33224 --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadNeedactionPreview" owl="1"> + <!-- + The preview template is used by the discuss in mobile, and by the systray + menu in order to show preview of threads. + --> + <div class="o_ThreadNeedactionPreview" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined"> + <t t-if="thread"> + <div class="o_ThreadNeedactionPreview_sidebar"> + <div class="o_ThreadNeedactionPreview_imageContainer o_ThreadNeedactionPreview_sidebarItem"> + <img class="o_ThreadNeedactionPreview_image" t-att-src="image()" alt="Thread Image"/> + <t t-if="thread.correspondent and thread.correspondent.im_status"> + <PartnerImStatusIcon + class="o_ThreadNeedactionPreview_partnerImStatusIcon" + t-att-class="{ + 'o-mobile': env.messaging.device.isMobile, + }" + partnerLocalId="thread.correspondent.localId" + /> + </t> + </div> + </div> + <div class="o_ThreadNeedactionPreview_content"> + <div class="o_ThreadNeedactionPreview_header"> + <span class="o_ThreadNeedactionPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-esc="thread.displayName"/> + </span> + <span class="o_ThreadNeedactionPreview_counter"> + (<t t-esc="thread.needactionMessagesAsOriginThread.length"/>) + </span> + <span class="o-autogrow"/> + <t t-if="thread.lastNeedactionMessageAsOriginThread"> + <span class="o_ThreadNeedactionPreview_date"> + <t t-esc="thread.lastNeedactionMessageAsOriginThread.date.fromNow()"/> + </span> + </t> + </div> + <div class="o_ThreadNeedactionPreview_core"> + <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_inlineText" t-att-class="{ 'o-empty': inlineLastNeedactionMessageAsOriginThreadBody.length === 0 }"> + <t t-if="thread.lastNeedactionMessageAsOriginThread and thread.lastNeedactionMessageAsOriginThread.author"> + <MessageAuthorPrefix + messageLocalId="thread.lastNeedactionMessageAsOriginThread.localId" + threadLocalId="thread.localId" + /> + </t> + <t t-esc="inlineLastNeedactionMessageAsOriginThreadBody"/> + </span> + <span class="o-autogrow"/> + <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js new file mode 100644 index 00000000..ca1fe22c --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js @@ -0,0 +1,457 @@ +odoo.define('mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js', function (require) { +'use strict'; + +const components = { + ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'), +}; + +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_needaction_preview', {}, function () { +QUnit.module('thread_needaction_preview_tests.js', { + beforeEach() { + beforeEach(this); + + this.createThreadNeedactionPreviewComponent = async props => { + await createRootComponent(this, components.ThreadNeedactionPreview, { + props, + target: this.widget.el + }); + }; + + this.start = async params => { + const { afterEvent, env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.afterEvent = afterEvent; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('mark as read', async function (assert) { + assert.expect(5); + + this.data['mail.message'].records.push({ + id: 21, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 11, + }); + this.data['mail.notification'].records.push({ + mail_message_id: 21, + notification_status: 'sent', + notification_type: 'inbox', + res_partner_id: this.data.currentPartnerId, + }); + await this.start({ + hasChatWindow: true, + hasMessagingMenu: true, + async mockRPC(route, args) { + if (route.includes('mark_all_as_read')) { + assert.step('mark_all_as_read'); + assert.deepEqual( + args.kwargs.domain, + [ + ['model', '=', 'res.partner'], + ['res_id', '=', 11], + ], + "should mark all as read the correct thread" + ); + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-cache-loaded-messages', + func: () => document.querySelector('.o_MessagingMenu_toggler').click(), + message: "should wait until inbox loaded initial needaction messages", + predicate: ({ threadCache }) => { + return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox'; + }, + })); + assert.containsOnce( + document.body, + '.o_ThreadNeedactionPreview_markAsRead', + "should have 1 mark as read button" + ); + + await afterNextRender(() => + document.querySelector('.o_ThreadNeedactionPreview_markAsRead').click() + ); + assert.verifySteps( + ['mark_all_as_read'], + "should have marked the thread as read" + ); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should not have opened the thread" + ); +}); + +QUnit.test('click on preview should mark as read and open the thread', async function (assert) { + assert.expect(6); + + this.data['mail.message'].records.push({ + id: 21, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 11, + }); + this.data['mail.notification'].records.push({ + mail_message_id: 21, + notification_status: 'sent', + notification_type: 'inbox', + res_partner_id: this.data.currentPartnerId, + }); + await this.start({ + hasChatWindow: true, + hasMessagingMenu: true, + async mockRPC(route, args) { + if (route.includes('mark_all_as_read')) { + assert.step('mark_all_as_read'); + assert.deepEqual( + args.kwargs.domain, + [ + ['model', '=', 'res.partner'], + ['res_id', '=', 11], + ], + "should mark all as read the correct thread" + ); + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-cache-loaded-messages', + func: () => document.querySelector('.o_MessagingMenu_toggler').click(), + message: "should wait until inbox loaded initial needaction messages", + predicate: ({ threadCache }) => { + return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox'; + }, + })); + assert.containsOnce( + document.body, + '.o_ThreadNeedactionPreview', + "should have a preview initially" + ); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should have no chat window initially" + ); + + await afterNextRender(() => + document.querySelector('.o_ThreadNeedactionPreview').click() + ); + assert.verifySteps( + ['mark_all_as_read'], + "should have marked the message as read on clicking on the preview" + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have opened the thread on clicking on the preview" + ); +}); + +QUnit.test('click on expand from chat window should close the chat window and open the form view', async function (assert) { + assert.expect(8); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.res_id, + 11, + "should redirect to the id of the thread" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "should redirect to the model of the thread" + ); + }); + this.data['mail.message'].records.push({ + id: 21, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 11, + }); + this.data['mail.notification'].records.push({ + mail_message_id: 21, + notification_status: 'sent', + notification_type: 'inbox', + res_partner_id: this.data.currentPartnerId, + }); + await this.start({ + env: { bus }, + hasChatWindow: true, + hasMessagingMenu: true, + }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-cache-loaded-messages', + func: () => document.querySelector('.o_MessagingMenu_toggler').click(), + message: "should wait until inbox loaded initial needaction messages", + predicate: ({ threadCache }) => { + return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox'; + }, + })); + assert.containsOnce( + document.body, + '.o_ThreadNeedactionPreview', + "should have a preview initially" + ); + await afterNextRender(() => + document.querySelector('.o_ThreadNeedactionPreview').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have opened the thread on clicking on the preview" + ); + assert.containsOnce( + document.body, + '.o_ChatWindowHeader_commandExpand', + "should have an expand button" + ); + + await afterNextRender(() => + document.querySelector('.o_ChatWindowHeader_commandExpand').click() + ); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should have closed the chat window on clicking expand" + ); + assert.verifySteps( + ['do_action'], + "should have done an action to open the form view" + ); +}); + +QUnit.test('[technical] opening a non-channel chat window should not call channel_fold', async function (assert) { + // channel_fold should not be called when opening non-channels in chat + // window, because there is no server sync of fold state for them. + assert.expect(3); + + this.data['mail.message'].records.push({ + id: 21, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 11, + }); + this.data['mail.notification'].records.push({ + mail_message_id: 21, + notification_status: 'sent', + notification_type: 'inbox', + res_partner_id: this.data.currentPartnerId, + }); + await this.start({ + hasChatWindow: true, + hasMessagingMenu: true, + async mockRPC(route, args) { + if (route.includes('channel_fold')) { + const message = "should not call channel_fold when opening a non-channel chat window"; + assert.step(message); + console.error(message); + throw Error(message); + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-cache-loaded-messages', + func: () => document.querySelector('.o_MessagingMenu_toggler').click(), + message: "should wait until inbox loaded initial needaction messages", + predicate: ({ threadCache }) => { + return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox'; + }, + })); + assert.containsOnce( + document.body, + '.o_ThreadNeedactionPreview', + "should have a preview initially" + ); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should have no chat window initially" + ); + + await afterNextRender(() => + document.querySelector('.o_ThreadNeedactionPreview').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have opened the chat window on clicking on the preview" + ); +}); + +QUnit.test('preview should display last needaction message preview even if there is a more recent message that is not needaction in the thread', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ + id: 11, + name: "Stranger", + }); + this.data['mail.message'].records.push({ + author_id: 11, + body: "I am the oldest but needaction", + id: 21, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 11, + }); + this.data['mail.message'].records.push({ + author_id: this.data.currentPartnerId, + body: "I am more recent", + id: 22, + model: 'res.partner', + res_id: 11, + }); + this.data['mail.notification'].records.push({ + mail_message_id: 21, + notification_status: 'sent', + notification_type: 'inbox', + res_partner_id: this.data.currentPartnerId, + }); + await this.start({ + hasChatWindow: true, + hasMessagingMenu: true, + }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-cache-loaded-messages', + func: () => document.querySelector('.o_MessagingMenu_toggler').click(), + message: "should wait until inbox loaded initial needaction messages", + predicate: ({ threadCache }) => { + return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox'; + }, + })); + assert.containsOnce( + document.body, + '.o_ThreadNeedactionPreview_inlineText', + "should have a preview from the last message" + ); + assert.strictEqual( + document.querySelector('.o_ThreadNeedactionPreview_inlineText').textContent, + 'Stranger: I am the oldest but needaction', + "the displayed message should be the one that needs action even if there is a more recent message that is not needaction on the thread" + ); +}); + +QUnit.test('needaction preview should only show on its origin thread', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 12 }); + this.data['mail.message'].records.push({ + channel_ids: [12], + id: 21, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 11, + }); + this.data['mail.notification'].records.push({ + mail_message_id: 21, + notification_status: 'sent', + notification_type: 'inbox', + res_partner_id: this.data.currentPartnerId, + }); + await this.start({ hasMessagingMenu: true }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-cache-loaded-messages', + func: () => document.querySelector('.o_MessagingMenu_toggler').click(), + message: "should wait until inbox loaded initial needaction messages", + predicate: ({ threadCache }) => { + return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox'; + }, + })); + assert.containsOnce( + document.body, + '.o_ThreadNeedactionPreview', + "should have only one preview" + ); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'res.partner', + }); + assert.containsOnce( + document.body, + `.o_ThreadNeedactionPreview[data-thread-local-id="${thread.localId}"]`, + "preview should be on the origin thread" + ); +}); + +QUnit.test('chat window header should not have unread counter for non-channel thread', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ id: 11 }); + this.data['mail.message'].records.push({ + author_id: 11, + body: 'not empty', + id: 21, + model: 'res.partner', + needaction: true, + needaction_partner_ids: [this.data.currentPartnerId], + res_id: 11, + }); + this.data['mail.notification'].records.push({ + mail_message_id: 21, + notification_status: 'sent', + notification_type: 'inbox', + res_partner_id: this.data.currentPartnerId, + }); + await this.start({ + hasChatWindow: true, + hasMessagingMenu: true, + }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-cache-loaded-messages', + func: () => document.querySelector('.o_MessagingMenu_toggler').click(), + message: "should wait until inbox loaded initial needaction messages", + predicate: ({ threadCache }) => { + return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox'; + }, + })); + await afterNextRender(() => + document.querySelector('.o_ThreadNeedactionPreview').click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have opened the chat window on clicking on the preview" + ); + assert.containsNone( + document.body, + '.o_ChatWindowHeader_counter', + "chat window header should not have unread counter for non-channel thread" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.js b/addons/mail/static/src/components/thread_preview/thread_preview.js new file mode 100644 index 00000000..94df29e0 --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.js @@ -0,0 +1,130 @@ +odoo.define('mail/static/src/components/thread_preview/thread_preview.js', function (require) { +'use strict'; + +const components = { + MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const mailUtils = require('mail.utils'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadPreview extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + let lastMessageAuthor; + let lastMessage; + if (thread) { + const orderedMessages = thread.orderedMessages; + lastMessage = orderedMessages[orderedMessages.length - 1]; + } + if (lastMessage) { + lastMessageAuthor = lastMessage.author; + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + lastMessage: lastMessage ? lastMessage.__state : undefined, + lastMessageAuthor: lastMessageAuthor + ? lastMessageAuthor.__state + : undefined, + thread: thread ? thread.__state : undefined, + threadCorrespondent: thread && thread.correspondent + ? thread.correspondent.__state + : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the image route of the thread. + * + * @returns {string} + */ + image() { + if (this.thread.correspondent) { + return this.thread.correspondent.avatarUrl; + } + return `/web/image/mail.channel/${this.thread.id}/image_128`; + } + + /** + * Get inline content of the last message of this conversation. + * + * @returns {string} + */ + get inlineLastMessageBody() { + if (!this.thread.lastMessage) { + return ''; + } + return mailUtils.htmlToTextContentInline(this.thread.lastMessage.prettyBody); + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.thread.open(); + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + if (this.thread.lastNonTransientMessage) { + this.thread.markAsSeen(this.thread.lastNonTransientMessage); + } + } + +} + +Object.assign(ThreadPreview, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadPreview', +}); + +return ThreadPreview; + +}); diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.scss b/addons/mail/static/src/components/thread_preview/thread_preview.scss new file mode 100644 index 00000000..772d63e2 --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.scss @@ -0,0 +1,117 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadPreview { + @include o-mail-notification-list-item-layout(); + + &:hover .o_ThreadPreview_markAsRead { + opacity: 1; + } +} + +.o_ThreadPreview_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_ThreadPreview_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_ThreadPreview_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_ThreadPreview_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_ThreadPreview_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_ThreadPreview_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_ThreadPreview_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_ThreadPreview_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_ThreadPreview_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_ThreadPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_ThreadPreview_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-layout(); +} + +.o_ThreadPreview_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadPreview { + @include o-mail-notification-list-item-style(); + + &:hover { + .o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-hover-partner-im-status-icon-style(); + } + } + + &.o-muted { + &:hover { + .o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-muted-hover-partner-im-status-icon-style(); + } + } + } +} + +.o_ThreadPreview_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_ThreadPreview_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadPreview_date { + @include o-mail-notification-list-item-date-style(); + + &.o-muted { + color: gray('500'); + } +} + +.o_ThreadPreview_image { + @include o-mail-notification-list-item-image-style(); +} + +.o_ThreadPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_ThreadPreview_name { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-style(); +} diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.xml b/addons/mail/static/src/components/thread_preview/thread_preview.xml new file mode 100644 index 00000000..8a4baf3d --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadPreview" owl="1"> + <!-- + The preview template is used by the discuss in mobile, and by the systray + menu in order to show preview of threads. + --> + <div class="o_ThreadPreview" t-att-class="{ 'o-muted': thread and thread.localMessageUnreadCounter === 0 }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined"> + <t t-if="thread"> + <div class="o_ThreadPreview_sidebar"> + <div class="o_ThreadPreview_imageContainer o_ThreadPreview_sidebarItem"> + <img class="o_ThreadPreview_image rounded-circle" t-att-src="image()" alt="Thread Image"/> + <t t-if="thread.correspondent and thread.correspondent.im_status"> + <PartnerImStatusIcon + class="o_ThreadPreview_partnerImStatusIcon" + t-att-class="{ + 'o-mobile': env.messaging.device.isMobile, + 'o-muted': thread.localMessageUnreadCounter === 0, + }" + partnerLocalId="thread.correspondent.localId" + /> + </t> + </div> + </div> + <div class="o_ThreadPreview_content"> + <div class="o_ThreadPreview_header"> + <span class="o_ThreadPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-muted': thread.localMessageUnreadCounter === 0 }"> + <t t-esc="thread.displayName"/> + </span> + <t t-if="thread.localMessageUnreadCounter > 0"> + <span class="o_ThreadPreview_counter" t-att-class="{ 'o-muted': thread.localMessageUnreadCounter === 0 }"> + (<t t-esc="thread.localMessageUnreadCounter"/>) + </span> + </t> + <span class="o-autogrow"/> + <t t-if="thread.lastMessage"> + <span class="o_ThreadPreview_date" t-att-class="{ 'o-muted': thread.localMessageUnreadCounter === 0 }"> + <t t-esc="thread.lastMessage.date.fromNow()"/> + </span> + </t> + </div> + <div class="o_ThreadPreview_core"> + <span class="o_ThreadPreview_coreItem o_ThreadPreview_inlineText" t-att-class="{ 'o-empty': inlineLastMessageBody.length === 0 }"> + <t t-if="thread.lastMessage and thread.lastMessage.author"> + <MessageAuthorPrefix + messageLocalId="thread.lastMessage.localId" + threadLocalId="thread.localId" + /> + </t> + <t t-esc="inlineLastMessageBody"/> + </span> + <span class="o-autogrow"/> + <t t-if="thread.localMessageUnreadCounter > 0"> + <span class="o_ThreadPreview_coreItem o_ThreadPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/> + </t> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_preview/thread_preview_tests.js b/addons/mail/static/src/components/thread_preview/thread_preview_tests.js new file mode 100644 index 00000000..981abf6b --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview_tests.js @@ -0,0 +1,114 @@ +odoo.define('mail/static/src/components/thread_preview/thread_preview_tests.js', function (require) { +'use strict'; + +const components = { + ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'), +}; + +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_preview', {}, function () { +QUnit.module('thread_preview_tests.js', { + beforeEach() { + beforeEach(this); + + this.createThreadPreviewComponent = async props => { + await createRootComponent(this, components.ThreadPreview, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('mark as read', async function (assert) { + assert.expect(8); + this.data['mail.channel'].records.push({ + id: 11, + message_unread_counter: 1, + }); + this.data['mail.message'].records.push({ + channel_ids: [11], + id: 100, + model: 'mail.channel', + res_id: 11, + }); + + await this.start({ + hasChatWindow: true, + async mockRPC(route, args) { + if (route.includes('channel_seen')) { + assert.step('channel_seen'); + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }); + await this.createThreadPreviewComponent({ threadLocalId: thread.localId }); + assert.containsOnce( + document.body, + '.o_ThreadPreview_markAsRead', + "should have the mark as read button" + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_counter', + "should have an unread counter" + ); + + await afterNextRender(() => + document.querySelector('.o_ThreadPreview_markAsRead').click() + ); + assert.verifySteps( + ['channel_seen'], + "should have marked the thread as seen" + ); + assert.hasClass( + document.querySelector('.o_ThreadPreview'), + 'o-muted', + "should be muted once marked as read" + ); + assert.containsNone( + document.body, + '.o_ThreadPreview_markAsRead', + "should no longer have the mark as read button" + ); + assert.containsNone( + document.body, + '.o_ThreadPreview_counter', + "should no longer have an unread counter" + ); + assert.containsNone( + document.body, + '.o_ChatWindow', + "should not have opened the thread" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js new file mode 100644 index 00000000..f053abc7 --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js @@ -0,0 +1,52 @@ +odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js', function (require) { +'use strict'; + +const components = { + ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ThreadTextualTypingStatus extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + threadOrderedOtherTypingMembersLength: thread && thread.orderedOtherTypingMembersLength, + threadTypingStatusText: thread && thread.typingStatusText, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(ThreadTextualTypingStatus, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadTextualTypingStatus', +}); + +return ThreadTextualTypingStatus; + +}); diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss new file mode 100644 index 00000000..4cb9e1cf --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss @@ -0,0 +1,12 @@ + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadTextualTypingStatus { + display: flex; +} + +.o_ThreadTextualTypingStatus_separator { + width: map-get($spacers, 1); +} diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml new file mode 100644 index 00000000..722d0738 --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadTextualTypingStatus" owl="1"> + <div class="o_ThreadTextualTypingStatus"> + <t t-if="thread and thread.orderedOtherTypingMembers.length > 0"> + <ThreadTypingIcon animation="'pulse'" size="'medium'"/> + <span class="o_ThreadTextualTypingStatus_separator"/> + <span t-esc="thread.typingStatusText"/> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js new file mode 100644 index 00000000..284ca788 --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js @@ -0,0 +1,367 @@ +odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js', function (require) { +'use strict'; + +const components = { + ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_textual_typing_status', {}, function () { +QUnit.module('thread_textual_typing_status_tests.js', { + beforeEach() { + beforeEach(this); + + this.createThreadTextualTypingStatusComponent = async thread => { + await createRootComponent(this, components.ThreadTextualTypingStatus, { + props: { threadLocalId: thread.localId }, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + async afterEach() { + afterEach(this); + }, +}); + +QUnit.test('receive other member typing status "is typing"', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ id: 17, name: 'Demo' }); + this.data['mail.channel'].records.push({ + id: 20, + members: [this.data.currentPartnerId, 17], + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 17, + partner_name: "Demo", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); +}); + +QUnit.test('receive other member typing status "is typing" then "no longer is typing"', async function (assert) { + assert.expect(3); + + this.data['res.partner'].records.push({ id: 17, name: 'Demo' }); + this.data['mail.channel'].records.push({ + id: 20, + members: [this.data.currentPartnerId, 17], + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 17, + partner_name: "Demo", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + // simulate receive typing notification from demo "is no longer typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: false, + partner_id: 17, + partner_name: "Demo", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing" + ); +}); + +QUnit.test('assume other member typing status becomes "no longer is typing" after 60 seconds without any updated typing status', async function (assert) { + assert.expect(3); + + this.data['res.partner'].records.push({ id: 17, name: 'Demo' }); + this.data['mail.channel'].records.push({ + id: 20, + members: [this.data.currentPartnerId, 17], + }); + await this.start({ + hasTimeControl: true, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 17, + partner_name: "Demo", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + await afterNextRender(() => this.env.testUtils.advanceTime(60 * 1000)); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing" + ); +}); + +QUnit.test ('other member typing status "is typing" refreshes 60 seconds timer of assuming no longer typing', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ id: 17, name: 'Demo' }); + this.data['mail.channel'].records.push({ + id: 20, + members: [this.data.currentPartnerId, 17], + }); + await this.start({ + hasTimeControl: true, + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 17, + partner_name: "Demo", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + // simulate receive typing notification from demo "is typing" again after 50s. + await this.env.testUtils.advanceTime(50 * 1000); + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 17, + partner_name: "Demo", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + await this.env.testUtils.advanceTime(50 * 1000); + await nextAnimationFrame(); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should still display that demo user is typing after 100 seconds (refreshed is typing status at 50s => (100 - 50) = 50s < 60s after assuming no-longer typing)" + ); + + await afterNextRender(() => this.env.testUtils.advanceTime(11 * 1000)); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing after 111 seconds (refreshed is typing status at 50s => (111 - 50) = 61s > 60s after assuming no-longer typing)" + ); +}); + +QUnit.test('receive several other members typing status "is typing"', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push( + { id: 10, name: 'Other10' }, + { id: 11, name: 'Other11' }, + { id: 12, name: 'Other12' } + ); + this.data['mail.channel'].records.push({ + id: 20, + members: [this.data.currentPartnerId, 10, 11, 12], + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from other10 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 10, + partner_name: "Other10", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10 is typing...", + "Should display that 'Other10' member is typing" + ); + + // simulate receive typing notification from other11 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 11, + partner_name: "Other11", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10 and Other11 are typing...", + "Should display that members 'Other10' and 'Other11' are typing (order: longer typer named first)" + ); + + // simulate receive typing notification from other12 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 12, + partner_name: "Other12", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10, Other11 and more are typing...", + "Should display that members 'Other10', 'Other11' and more (at least 1 extra member) are typing (order: longer typer named first)" + ); + + // simulate receive typing notification from other10 (no longer is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: false, + partner_id: 10, + partner_name: "Other10", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other11 and Other12 are typing...", + "Should display that members 'Other11' and 'Other12' are typing ('Other10' stopped typing)" + ); + + // simulate receive typing notification from other10 (is typing again) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + is_typing: true, + partner_id: 10, + partner_name: "Other10", + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other11, Other12 and more are typing...", + "Should display that members 'Other11' and 'Other12' and more (at least 1 extra member) are typing (order by longer typer, 'Other10' just recently restarted typing)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js new file mode 100644 index 00000000..4c94a749 --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js @@ -0,0 +1,41 @@ +odoo.define('mail/static/src/components/thread_typing_icon/thread_typing_icon.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); + +const { Component } = owl; + +class ThreadTypingIcon extends Component { + + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + } + +} + +Object.assign(ThreadTypingIcon, { + defaultProps: { + animation: 'none', + size: 'small', + }, + props: { + animation: { + type: String, + validate: prop => ['bounce', 'none', 'pulse'].includes(prop), + }, + size: { + type: String, + validate: prop => ['small', 'medium'].includes(prop), + }, + title: { + type: String, + optional: true, + } + }, + template: 'mail.ThreadTypingIcon', +}); + +return ThreadTypingIcon; + +}); diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss new file mode 100644 index 00000000..ac3c5b2f --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss @@ -0,0 +1,108 @@ +// ------------------------------------------------------------------ +// Variables +// ------------------------------------------------------------------ + +$o-thread-typing-icon-size-medium: 5px; +$o-thread-typing-icon-size-small: 3px; + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon { + display: flex; + align-items: center; +} + +.o_ThreadTypingIcon_dot { + display: flex; + flex: 0 0 auto; +} + +.o_ThreadTypingIcon_separator { + min-width: 1px; + flex: 1 0 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon_dot { + border-radius: 50%; + background: gray('500'); + + &.o-sizeMedium { + width: $o-thread-typing-icon-size-medium; + height: $o-thread-typing-icon-size-medium; + } + + &.o-sizeSmall { + width: $o-thread-typing-icon-size-small; + height: $o-thread-typing-icon-size-small; + } +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon_dot.o-animationBounce { + + // Note: duplicated animation because dependent on size, and current SASS version doesn't support var() + &.o-sizeMedium { + animation: o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation 1.5s linear infinite; + } + + &.o-sizeSmall { + animation: o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation 1.5s linear infinite; + } + + &.o_ThreadTypingIcon_dot2 { + animation-delay: -1.35s; + } + + &.o_ThreadTypingIcon_dot3 { + animation-delay: -1.2s; + } +} + +.o_ThreadTypingIcon_dot.o-animationPulse { + animation: o_ThreadTypingIcon_dot_animationPulse_animation 1.5s linear infinite; + + &.o_ThreadTypingIcon_dot2 { + animation-delay: -1.35s; + } + + &.o_ThreadTypingIcon_dot3 { + animation-delay: -1.2s; + } +} + +@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation { + 0%, 40%, 100% { + transform: initial; + } + 20% { + transform: translateY(-$o-thread-typing-icon-size-medium); + } +} + +@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation { + 0%, 40%, 100% { + transform: initial; + } + 20% { + transform: translateY(-$o-thread-typing-icon-size-small); + } +} + + +@keyframes o_ThreadTypingIcon_dot_animationPulse_animation { + 0%, 40%, 100% { + opacity: initial; + } + 20% { + opacity: 25%; + } +} diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml new file mode 100644 index 00000000..1bdb4ada --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadTypingIcon" owl="1"> + <div class="o_ThreadTypingIcon" t-att-title="props.title"> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot1" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + <span class="o_ThreadTypingIcon_separator"/> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot2" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + <span class="o_ThreadTypingIcon_separator"/> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot3" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_view/thread_view.js b/addons/mail/static/src/components/thread_view/thread_view.js new file mode 100644 index 00000000..2399fd16 --- /dev/null +++ b/addons/mail/static/src/components/thread_view/thread_view.js @@ -0,0 +1,222 @@ +odoo.define('mail/static/src/components/thread_view/thread_view.js', function (require) { +'use strict'; + +const components = { + Composer: require('mail/static/src/components/composer/composer.js'), + MessageList: require('mail/static/src/components/message_list/message_list.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadView extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore((...args) => this._useStoreSelector(...args), { + compareDepth: { + threadTextInputSendShortcuts: 1, + }, + }); + useUpdate({ func: () => this._update() }); + /** + * Reference of the composer. Useful to set focus on composer when + * thread has the focus. + */ + this._composerRef = useRef('composer'); + /** + * Reference of the message list. Useful to determine scroll positions. + */ + this._messageListRef = useRef('messageList'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Focus the thread. If it has a composer, focus it. + */ + focus() { + if (!this._composerRef.comp) { + return; + } + this._composerRef.comp.focus(); + } + + /** + * Focusout the thread. + */ + focusout() { + if (!this._composerRef.comp) { + return; + } + this._composerRef.comp.focusout(); + } + + /** + * Get the scroll height in the message list. + * + * @returns {integer|undefined} + */ + getScrollHeight() { + if (!this._messageListRef.comp) { + return undefined; + } + return this._messageListRef.comp.getScrollHeight(); + } + + /** + * Get the scroll position in the message list. + * + * @returns {integer|undefined} + */ + getScrollTop() { + if (!this._messageListRef.comp) { + return undefined; + } + return this._messageListRef.comp.getScrollTop(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + onScroll(ev) { + if (!this._messageListRef.comp) { + return; + } + this._messageListRef.comp.onScroll(ev); + } + + /** + * @returns {mail.thread_view} + */ + get threadView() { + return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when thread component is mounted or patched. + * + * @private + */ + _update() { + this.trigger('o-rendered'); + } + + /** + * Returns data selected from the store. + * + * @private + * @param {Object} props + * @returns {Object} + */ + _useStoreSelector(props) { + const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId); + const thread = threadView ? threadView.thread : undefined; + const threadCache = threadView ? threadView.threadCache : undefined; + const correspondent = thread && thread.correspondent; + return { + composer: thread && thread.composer, + correspondentId: correspondent && correspondent.id, + isDeviceMobile: this.env.messaging.device.isMobile, + thread, + threadCacheIsLoaded: threadCache && threadCache.isLoaded, + threadIsTemporary: thread && thread.isTemporary, + threadMassMailing: thread && thread.mass_mailing, + threadModel: thread && thread.model, + threadTextInputSendShortcuts: thread && thread.textInputSendShortcuts || [], + threadView, + threadViewIsLoading: threadView && threadView.isLoading, + }; + } + +} + +Object.assign(ThreadView, { + components, + defaultProps: { + composerAttachmentsDetailsMode: 'auto', + hasComposer: false, + hasMessageCheckbox: false, + hasSquashCloseMessages: false, + haveMessagesMarkAsReadIcon: false, + haveMessagesReplyIcon: false, + isDoFocus: false, + order: 'asc', + showComposerAttachmentsExtensions: true, + showComposerAttachmentsFilenames: true, + }, + props: { + composerAttachmentsDetailsMode: { + type: String, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + hasComposer: Boolean, + hasComposerCurrentPartnerAvatar: { + type: Boolean, + optional: true, + }, + hasComposerSendButton: { + type: Boolean, + optional: true, + }, + /** + * If set, determines whether the composer should display status of + * members typing on related thread. When this prop is not provided, + * it defaults to composer component default value. + */ + hasComposerThreadTyping: { + type: Boolean, + optional: true, + }, + hasMessageCheckbox: Boolean, + hasScrollAdjust: { + type: Boolean, + optional: true, + }, + hasSquashCloseMessages: Boolean, + haveMessagesMarkAsReadIcon: Boolean, + haveMessagesReplyIcon: Boolean, + /** + * Determines whether this should become focused. + */ + isDoFocus: Boolean, + order: { + type: String, + validate: prop => ['asc', 'desc'].includes(prop), + }, + selectedMessageLocalId: { + type: String, + optional: true, + }, + /** + * Function returns the exact scrollable element from the parent + * to manage proper scroll heights which affects the load more messages. + */ + getScrollableElement: { + type: Function, + optional: true, + }, + showComposerAttachmentsExtensions: Boolean, + showComposerAttachmentsFilenames: Boolean, + threadViewLocalId: String, + }, + template: 'mail.ThreadView', +}); + +return ThreadView; + +}); diff --git a/addons/mail/static/src/components/thread_view/thread_view.scss b/addons/mail/static/src/components/thread_view/thread_view.scss new file mode 100644 index 00000000..0db54501 --- /dev/null +++ b/addons/mail/static/src/components/thread_view/thread_view.scss @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadView { + display: flex; + position: relative; + flex-flow: column; + overflow: auto; +} + +.o_ThreadView_composer { + flex: 0 0 auto; +} + +.o_ThreadView_loading { + display: flex; + align-self: center; + flex: 1 1 auto; + align-items: center; +} + +.o_ThreadView_messageList { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadView { + background-color: gray('100'); + +} + +.o_ThreadView_loadingIcon { + margin-right: 3px; +} diff --git a/addons/mail/static/src/components/thread_view/thread_view.xml b/addons/mail/static/src/components/thread_view/thread_view.xml new file mode 100644 index 00000000..8f06bf39 --- /dev/null +++ b/addons/mail/static/src/components/thread_view/thread_view.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadView" owl="1"> + <div class="o_ThreadView" t-att-data-correspondent-id="threadView and threadView.thread and threadView.thread.correspondent and threadView.thread.correspondent.id" t-att-data-thread-local-id="threadView and threadView.thread and threadView.thread.localId"> + <t t-if="threadView"> + <t t-if="threadView.isLoading and !threadView.threadCache.isLoaded" name="loadingCondition"> + <div class="o_ThreadView_loading"> + <span><i class="o_ThreadView_loadingIcon fa fa-spinner fa-spin" title="Loading..." role="img"/>Loading...</span> + </div> + </t> + <t t-elif="threadView.threadCache.isLoaded or threadView.thread.isTemporary"> + <MessageList + class="o_ThreadView_messageList" + getScrollableElement= "props.getScrollableElement" + hasMessageCheckbox="props.hasMessageCheckbox" + hasScrollAdjust="props.hasScrollAdjust" + hasSquashCloseMessages="props.hasSquashCloseMessages" + haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon" + haveMessagesReplyIcon="props.haveMessagesReplyIcon" + order="props.order" + selectedMessageLocalId="props.selectedMessageLocalId" + threadViewLocalId="threadView.localId" + t-ref="messageList" + /> + </t> + <t t-elif="props.hasComposer"> + <div class="o-autogrow"/> + </t> + <t t-if="props.hasComposer"> + <Composer + class="o_ThreadView_composer" + attachmentsDetailsMode="props.composerAttachmentsDetailsMode" + composerLocalId="threadView.thread.composer.localId" + hasCurrentPartnerAvatar="props.hasComposerCurrentPartnerAvatar" + hasSendButton="props.hasComposerSendButton" + hasThreadTyping="props.hasComposerThreadTyping" + isCompact="(threadView.thread.model === 'mail.channel' and threadView.thread.mass_mailing) ? false : undefined" + isDoFocus="props.isDoFocus" + showAttachmentsExtensions="props.showComposerAttachmentsExtensions" + showAttachmentsFilenames="props.showComposerAttachmentsFilenames" + textInputSendShortcuts="threadView.textInputSendShortcuts" + t-ref="composer" + /> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_view/thread_view_tests.js b/addons/mail/static/src/components/thread_view/thread_view_tests.js new file mode 100644 index 00000000..58a05989 --- /dev/null +++ b/addons/mail/static/src/components/thread_view/thread_view_tests.js @@ -0,0 +1,1809 @@ +odoo.define('mail/static/src/components/thread_view/thread_view_tests.js', function (require) { +'use strict'; + +const components = { + ThreadView: require('mail/static/src/components/thread_view/thread_view.js'), +}; +const { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + dragenterFiles, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_view', {}, function () { +QUnit.module('thread_view_tests.js', { + beforeEach() { + beforeEach(this); + + /** + * @param {mail.thread_view} threadView + * @param {Object} [otherProps={}] + * @param {Object} [param2={}] + * @param {boolean} [param2.isFixedSize=false] + */ + this.createThreadViewComponent = async (threadView, otherProps = {}, { isFixedSize = false } = {}) => { + let target; + if (isFixedSize) { + // needed to allow scrolling in some tests + const div = document.createElement('div'); + Object.assign(div.style, { + display: 'flex', + 'flex-flow': 'column', + height: '300px', + }); + this.widget.el.append(div); + target = div; + } else { + target = this.widget.el; + } + const props = Object.assign({ threadViewLocalId: threadView.localId }, otherProps); + await createRootComponent(this, components.ThreadView, { props, target }); + }; + + this.start = async params => { + const { afterEvent, env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.afterEvent = afterEvent; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('dragover files on thread with composer', async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_ThreadView')) + ); + assert.ok( + document.querySelector('.o_Composer_dropZone'), + "should have dropzone when dragging file over the thread" + ); +}); + +QUnit.test('message list desc order', async function (assert) { + assert.expect(5); + + for (let i = 0; i <= 60; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + }); + } + await this.start(); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => this.createThreadViewComponent(threadViewer.threadView, { order: 'desc' }, { isFixedSize: true }), + message: "should wait until channel 100 loaded initial messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 100 + ); + }, + }); + const messageItems = document.querySelectorAll(`.o_MessageList_item`); + assert.notOk( + messageItems[0].classList.contains("o_MessageList_loadMore"), + "load more link should NOT be before messages" + ); + assert.ok( + messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"), + "load more link should be after messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "should have 30 messages at the beginning" + ); + + // scroll to bottom + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + messageList.scrollTop = messageList.scrollHeight - messageList.clientHeight; + }, + message: "should wait until channel 100 loaded more messages after scrolling to bottom", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'more-messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 100 + ); + }, + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "should have 60 messages after scrolled to bottom" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ThreadView_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "scrolling to top should not trigger any message fetching" + ); +}); + +QUnit.test('message list asc order', async function (assert) { + assert.expect(5); + + for (let i = 0; i <= 60; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + }); + } + await this.start(); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => this.createThreadViewComponent(threadViewer.threadView, { order: 'asc' }, { isFixedSize: true }), + message: "should wait until channel 100 loaded initial messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 100 + ); + }, + }); + const messageItems = document.querySelectorAll(`.o_MessageList_item`); + assert.notOk( + messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"), + "load more link should be before messages" + ); + assert.ok( + messageItems[0].classList.contains("o_MessageList_loadMore"), + "load more link should NOT be after messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "should have 30 messages at the beginning" + ); + + // scroll to top + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => document.querySelector(`.o_ThreadView_messageList`).scrollTop = 0, + message: "should wait until channel 100 loaded more messages after scrolling to top", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'more-messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 100 + ); + }, + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "should have 60 messages after scrolled to top" + ); + + // scroll to bottom + await afterNextRender(() => { + document.querySelector(`.o_ThreadView_messageList`).scrollTop = + document.querySelector(`.o_ThreadView_messageList`).scrollHeight; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "scrolling to bottom should not trigger any message fetching" + ); +}); + +QUnit.test('mark channel as fetched when a new message is loaded and as seen when focusing composer [REQUIRE FOCUS]', async function (assert) { + assert.expect(8); + + this.data['res.partner'].records.push({ + email: "fred@example.com", + id: 10, + name: "Fred", + }); + this.data['res.users'].records.push({ + id: 10, + partner_id: 10, + }); + this.data['mail.channel'].records.push({ + channel_type: 'chat', + id: 100, + is_pinned: true, + members: [this.data.currentPartnerId, 10], + }); + await this.start({ + mockRPC(route, args) { + if (args.method === 'channel_fetched') { + assert.strictEqual( + args.args[0][0], + 100, + 'channel_fetched is called on the right channel id' + ); + assert.strictEqual( + args.model, + 'mail.channel', + 'channel_fetched is called on the right channel model' + ); + assert.step('rpc:channel_fetch'); + } else if (args.method === 'channel_seen') { + assert.strictEqual( + args.args[0][0], + 100, + 'channel_seen is called on the right channel id' + ); + assert.strictEqual( + args.model, + 'mail.channel', + 'channel_seeb is called on the right channel model' + ); + assert.step('rpc:channel_seen'); + } + return this._super(...arguments); + } + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + await afterNextRender(async () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 10, + }, + message_content: "new message", + uuid: thread.uuid, + }, + })); + assert.verifySteps( + ['rpc:channel_fetch'], + "Channel should have been fetched but not seen yet" + ); + + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => document.querySelector('.o_ComposerTextInput_textarea').focus(), + message: "should wait until last seen by current partner message id changed after focusing the thread", + predicate: ({ thread }) => { + return ( + thread.id === 100 && + thread.model === 'mail.channel' + ); + }, + })); + assert.verifySteps( + ['rpc:channel_seen'], + "Channel should have been marked as seen after threadView got the focus" + ); +}); + +QUnit.test('mark channel as fetched and seen when a new message is loaded if composer is focused [REQUIRE FOCUS]', async function (assert) { + assert.expect(4); + + this.data['res.partner'].records.push({ + id: 10, + }); + this.data['res.users'].records.push({ + id: 10, + partner_id: 10, + }); + this.data['mail.channel'].records.push({ + id: 100, + }); + await this.start({ + mockRPC(route, args) { + if (args.method === 'channel_fetched' && args.args[0] === 100) { + throw new Error("'channel_fetched' RPC must not be called for created channel as message is directly seen"); + } else if (args.method === 'channel_seen') { + assert.strictEqual( + args.args[0][0], + 100, + 'channel_seen is called on the right channel id' + ); + assert.strictEqual( + args.model, + 'mail.channel', + 'channel_seen is called on the right channel model' + ); + assert.step('rpc:channel_seen'); + } + return this._super(...arguments); + } + }); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + document.querySelector('.o_ComposerTextInput_textarea').focus(); + // simulate receiving a message + await this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 10, + }, + message_content: "<p>fdsfsd</p>", + uuid: thread.uuid, + }, + }), + message: "should wait until last seen by current partner message id changed after receiving a message while thread is focused", + predicate: ({ thread }) => { + return ( + thread.id === 100 && + thread.model === 'mail.channel' + ); + }, + }); + assert.verifySteps( + ['rpc:channel_seen'], + "Channel should have been mark as seen directly" + ); +}); + +QUnit.test('show message subject if thread is mailing channel', async function (assert) { + assert.expect(3); + + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [100], + model: 'mail.channel', + res_id: 100, + subject: "Salutations, voyageur", + }); + await this.start(); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + mass_mailing: true, + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a single message" + ); + assert.containsOnce( + document.body, + '.o_Message_subject', + "should display subject of the message" + ); + assert.strictEqual( + document.querySelector('.o_Message_subject').textContent, + "Subject: Salutations, voyageur", + "Subject of the message should be 'Salutations, voyageur'" + ); +}); + +QUnit.test('[technical] new messages separator on posting message', async function (assert) { + // technical as we need to remove focus from text input to avoid `channel_seen` call + assert.expect(4); + + this.data['mail.channel'].records = [{ + channel_type: 'channel', + id: 20, + is_pinned: true, + message_unread_counter: 0, + seen_message_id: 10, + name: "General", + }]; + this.data['mail.message'].records.push({ + body: "first message", + channel_ids: [20], + id: 10, + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel' + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display one message in thread initially" + ); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "should not display 'new messages' separator" + ); + + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => document.execCommand('insertText', false, "hey !")); + await afterNextRender(() => { + // need to remove focus from text area to avoid channel_seen + document.querySelector('.o_Composer_buttonSend').focus(); + document.querySelector('.o_Composer_buttonSend').click(); + + }); + assert.containsN( + document.body, + '.o_Message', + 2, + "should display 2 messages (initial & newly posted), after posting a message" + ); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "still no separator shown when current partner posted a message" + ); +}); + +QUnit.test('new messages separator on receiving new message [REQUIRE FOCUS]', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push({ + id: 11, + name: "Foreigner partner", + }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 11, + }); + this.data['mail.channel'].records.push({ + channel_type: 'channel', + id: 20, + is_pinned: true, + message_unread_counter: 0, + name: "General", + seen_message_id: 1, + uuid: 'randomuuid', + }); + this.data['mail.message'].records.push({ + body: "blah", + channel_ids: [20], + id: 1, + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel' + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + + assert.containsOnce( + document.body, + '.o_MessageList_message', + "should have an initial message" + ); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "should not display 'new messages' separator" + ); + + document.querySelector('.o_ComposerTextInput_textarea').blur(); + // simulate receiving a message + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hu", + uuid: thread.uuid, + }, + }), + message: "should wait until new message is received", + predicate: ({ hint, threadViewer }) => { + return ( + threadViewer.thread.id === 20 && + threadViewer.thread.model === 'mail.channel' && + hint.type === 'message-received' + ); + }, + }); + assert.containsN( + document.body, + '.o_Message', + 2, + "should now have 2 messages after receiving a new message" + ); + assert.containsOnce( + document.body, + '.o_MessageList_separatorNewMessages', + "'new messages' separator should be shown" + ); + + assert.containsOnce( + document.body, + `.o_MessageList_separatorNewMessages ~ .o_Message[data-message-local-id="${ + this.env.models['mail.message'].findFromIdentifyingData({ id: 2 }).localId + }"]`, + "'new messages' separator should be shown above new message received" + ); + + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => document.querySelector('.o_ComposerTextInput_textarea').focus(), + message: "should wait until last seen by current partner message id changed after focusing the thread", + predicate: ({ thread }) => { + return ( + thread.id === 20 && + thread.model === 'mail.channel' + ); + }, + })); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "'new messages' separator should no longer be shown as last message has been seen" + ); +}); + +QUnit.test('new messages separator on posting message', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records = [{ + channel_type: 'channel', + id: 20, + is_pinned: true, + message_unread_counter: 0, + name: "General", + }]; + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel' + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + + assert.containsNone( + document.body, + '.o_MessageList_message', + "should have no messages" + ); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "should not display 'new messages' separator" + ); + + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => document.execCommand('insertText', false, "hey !")); + await afterNextRender(() => + document.querySelector('.o_Composer_buttonSend').click() + ); + assert.containsOnce( + document.body, + '.o_Message', + "should have the message current partner just posted" + ); + assert.containsNone( + document.body, + '.o_MessageList_separatorNewMessages', + "still no separator shown when current partner posted a message" + ); +}); + +QUnit.test('basic rendering of canceled notification', async function (assert) { + assert.expect(8); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['res.partner'].records.push({ id: 12, name: "Someone" }); + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [11], + id: 10, + message_type: 'email', + model: 'mail.channel', + notification_ids: [11], + res_id: 11, + }); + this.data['mail.notification'].records.push({ + failure_type: 'SMTP', + id: 11, + mail_message_id: 10, + notification_status: 'canceled', + notification_type: 'email', + res_partner_id: 12, + }); + await this.start(); + const threadViewer = await this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['insert', { + id: 11, + model: 'mail.channel', + }]], + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + this.createThreadViewComponent(threadViewer.threadView); + }, + message: "thread become loaded with messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 + ); + }, + }); + + assert.containsOnce( + document.body, + '.o_Message_notificationIconClickable', + "should display the notification icon container on the message" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon on the message" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope-o', + "notification icon shown on the message should represent email" + ); + + await afterNextRender(() => { + document.querySelector('.o_Message_notificationIconClickable').click(); + }); + assert.containsOnce( + document.body, + '.o_NotificationPopover', + "notification popover should be opened after notification has been clicked" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationIcon', + "an icon should be shown in notification popover" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationIcon.fa.fa-trash-o', + "the icon shown in notification popover should be the canceled icon" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationPartnerName', + "partner name should be shown in notification popover" + ); + assert.strictEqual( + document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(), + "Someone", + "partner name shown in notification popover should be the one concerned by the notification" + ); +}); + +QUnit.test('should scroll to bottom on receiving new message if the list is initially scrolled to bottom (asc order)', async function (assert) { + assert.expect(2); + + // Needed partner & user to allow simulation of message reception + this.data['res.partner'].records.push({ + id: 11, + name: "Foreigner partner", + }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 11, + }); + this.data['mail.channel'].records.push({ id: 20 }); + for (let i = 0; i <= 10; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + }); + } + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel' + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => this.createThreadViewComponent( + threadViewer.threadView, + { order: 'asc' }, + { isFixedSize: true }, + ), + message: "should wait until channel 20 scrolled initially", + predicate: data => threadViewer === data.threadViewer, + }); + const initialMessageList = document.querySelector('.o_ThreadView_messageList'); + assert.strictEqual( + initialMessageList.scrollTop, + initialMessageList.scrollHeight - initialMessageList.clientHeight, + "should have scrolled to bottom of channel 20 initially" + ); + + // simulate receiving a message + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => + this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hello", + uuid: thread.uuid, + }, + }), + message: "should wait until channel 20 scrolled after receiving a message", + predicate: data => threadViewer === data.threadViewer, + }); + const messageList = document.querySelector('.o_ThreadView_messageList'); + assert.strictEqual( + messageList.scrollTop, + messageList.scrollHeight - messageList.clientHeight, + "should scroll to bottom on receiving new message because the list is initially scrolled to bottom" + ); +}); + +QUnit.test('should not scroll on receiving new message if the list is initially scrolled anywhere else than bottom (asc order)', async function (assert) { + assert.expect(3); + + // Needed partner & user to allow simulation of message reception + this.data['res.partner'].records.push({ + id: 11, + name: "Foreigner partner", + }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 11, + }); + this.data['mail.channel'].records.push({ id: 20 }); + for (let i = 0; i <= 10; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [20], + }); + } + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel' + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => this.createThreadViewComponent( + threadViewer.threadView, + { order: 'asc' }, + { isFixedSize: true }, + ), + message: "should wait until channel 20 scrolled initially", + predicate: data => threadViewer === data.threadViewer, + }); + const initialMessageList = document.querySelector('.o_ThreadView_messageList'); + assert.strictEqual( + initialMessageList.scrollTop, + initialMessageList.scrollHeight - initialMessageList.clientHeight, + "should have scrolled to bottom of channel 20 initially" + ); + + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => initialMessageList.scrollTop = 0, + message: "should wait until channel 20 processed manual scroll", + predicate: data => threadViewer === data.threadViewer, + }); + assert.strictEqual( + initialMessageList.scrollTop, + 0, + "should have scrolled to the top of channel 20 manually" + ); + + // simulate receiving a message + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => + this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hello", + uuid: thread.uuid, + }, + }), + message: "should wait until channel 20 processed new message hint", + predicate: data => threadViewer === data.threadViewer && data.hint.type === 'message-received', + }); + assert.strictEqual( + document.querySelector('.o_ThreadView_messageList').scrollTop, + 0, + "should not scroll on receiving new message because the list is initially scrolled anywhere else than bottom" + ); +}); + +QUnit.test("delete all attachments of message without content should no longer display the message", async function (assert) { + assert.expect(2); + + this.data['ir.attachment'].records.push({ + id: 143, + mimetype: 'text/plain', + name: "Blah.txt", + }); + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push( + { + attachment_ids: [143], + channel_ids: [11], + id: 101, + } + ); + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['insert', { id: 11, model: 'mail.channel' }]], + }); + // wait for messages of the thread to be loaded + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + this.createThreadViewComponent(threadViewer.threadView); + }, + message: "thread become loaded with messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "there should be 1 message displayed initially" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Attachment[data-attachment-local-id="${ + this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId + }"] .o_Attachment_asideItemUnlink`).click(); + }); + await afterNextRender(() => + document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click() + ); + assert.containsNone( + document.body, + '.o_Message', + "message should no longer be displayed after removing all its attachments (empty content)" + ); +}); + +QUnit.test('delete all attachments of a message with some text content should still keep it displayed', async function (assert) { + assert.expect(2); + + this.data['ir.attachment'].records.push({ + id: 143, + mimetype: 'text/plain', + name: "Blah.txt", + }); + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push( + { + attachment_ids: [143], + body: "Some content", + channel_ids: [11], + id: 101, + }, + ); + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['insert', { id: 11, model: 'mail.channel' }]], + }); + // wait for messages of the thread to be loaded + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + this.createThreadViewComponent(threadViewer.threadView); + }, + message: "thread become loaded with messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "there should be 1 message displayed initially" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Attachment[data-attachment-local-id="${ + this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId + }"] .o_Attachment_asideItemUnlink`).click(); + }); + await afterNextRender(() => + document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click() + ); + assert.containsOnce( + document.body, + '.o_Message', + "message should still be displayed after removing its attachments (non-empty content)" + ); +}); + +QUnit.test('delete all attachments of a message with tracking fields should still keep it displayed', async function (assert) { + assert.expect(2); + + this.data['ir.attachment'].records.push({ + id: 143, + mimetype: 'text/plain', + name: "Blah.txt", + }); + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push( + { + attachment_ids: [143], + channel_ids: [11], + id: 101, + tracking_value_ids: [6] + }, + ); + this.data['mail.tracking.value'].records.push({ + changed_field: "Name", + field_type: "char", + id: 6, + new_value: "New name", + old_value: "Old name", + }); + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['insert', { id: 11, model: 'mail.channel' }]], + }); + // wait for messages of the thread to be loaded + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + this.createThreadViewComponent(threadViewer.threadView); + }, + message: "thread become loaded with messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "there should be 1 message displayed initially" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Attachment[data-attachment-local-id="${ + this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId + }"] .o_Attachment_asideItemUnlink`).click(); + }); + await afterNextRender(() => + document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click() + ); + assert.containsOnce( + document.body, + '.o_Message', + "message should still be displayed after removing its attachments (non-empty content)" + ); +}); + +QUnit.test('Post a message containing an email address followed by a mention on another line', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['res.partner'].records.push({ + id: 25, + email: "testpartner@odoo.com", + name: "TestPartner", + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => document.execCommand('insertText', false, "email@odoo.com\n")); + await afterNextRender(() => { + ["@", "T", "e"].forEach((char)=>{ + document.execCommand('insertText', false, char); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + }); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); + assert.containsOnce( + document.querySelector(`.o_Message_content`), + `.o_mail_redirect[data-oe-id="25"][data-oe-model="res.partner"]:contains("@TestPartner")`, + "Conversation should have a message that has been posted, which contains partner mention" + ); +}); + +QUnit.test(`Mention a partner with special character (e.g. apostrophe ')`, async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['res.partner'].records.push({ + id: 1952, + email: "usatyi@example.com", + name: "Pynya's spokesman", + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => { + ["@", "P", "y", "n"].forEach((char)=>{ + document.execCommand('insertText', false, char); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + }); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); + assert.containsOnce( + document.querySelector(`.o_Message_content`), + `.o_mail_redirect[data-oe-id="1952"][data-oe-model="res.partner"]:contains("@Pynya's spokesman")`, + "Conversation should have a message that has been posted, which contains partner mention" + ); +}); + +QUnit.test('mention 2 different partners that have the same name', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['res.partner'].records.push( + { + id: 25, + email: "partner1@example.com", + name: "TestPartner", + }, { + id: 26, + email: "partner2@example.com", + name: "TestPartner", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => { + ["@", "T", "e"].forEach((char)=>{ + document.execCommand('insertText', false, char); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + }); + await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[0].click()); + await afterNextRender(() => { + ["@", "T", "e"].forEach((char)=>{ + document.execCommand('insertText', false, char); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + }); + await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[1].click()); + await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click()); + assert.containsOnce(document.body, '.o_Message_content', 'should have one message after posting it'); + assert.containsOnce( + document.querySelector(`.o_Message_content`), + `.o_mail_redirect[data-oe-id="25"][data-oe-model="res.partner"]:contains("@TestPartner")`, + "message should contain the first partner mention" + ); + assert.containsOnce( + document.querySelector(`.o_Message_content`), + `.o_mail_redirect[data-oe-id="26"][data-oe-model="res.partner"]:contains("@TestPartner")`, + "message should also contain the second partner mention" + ); +}); + +QUnit.test('mention a channel with space in the name', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General good boy", + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 7, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); + assert.containsOnce( + document.querySelector('.o_Message_content'), + '.o_channel_redirect', + "message must contain a link to the mentioned channel" + ); + assert.strictEqual( + document.querySelector('.o_channel_redirect').textContent, + '#General good boy', + "link to the channel must contains # + the channel name" + ); +}); + +QUnit.test('mention a channel with "&" in the name', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General & good", + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 7, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); + assert.containsOnce( + document.querySelector('.o_Message_content'), + '.o_channel_redirect', + "message should contain a link to the mentioned channel" + ); + assert.strictEqual( + document.querySelector('.o_channel_redirect').textContent, + '#General & good', + "link to the channel must contains # + the channel name" + ); +}); + +QUnit.test('mention a channel on a second line when the first line contains #', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General good", + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 7, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#blabla\n#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + await afterNextRender(() => { + document.querySelector('.o_ComposerSuggestion').click(); + }); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); + assert.containsOnce( + document.querySelector('.o_Message_content'), + '.o_channel_redirect', + "message should contain a link to the mentioned channel" + ); + assert.strictEqual( + document.querySelector('.o_channel_redirect').textContent, + '#General good', + "link to the channel must contains # + the channel name" + ); +}); + +QUnit.test('mention a channel when replacing the space after the mention by another char', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General good", + }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 7, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + await afterNextRender(() => { + document.querySelector('.o_ComposerSuggestion').click(); + }); + await afterNextRender(() => { + const text = document.querySelector(`.o_ComposerTextInput_textarea`).value; + document.querySelector(`.o_ComposerTextInput_textarea`).value = text.slice(0, -1); + document.execCommand('insertText', false, ", test"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); + assert.containsOnce( + document.querySelector('.o_Message_content'), + '.o_channel_redirect', + "message should contain a link to the mentioned channel" + ); + assert.strictEqual( + document.querySelector('.o_channel_redirect').textContent, + '#General good', + "link to the channel must contains # + the channel name" + ); +}); + +QUnit.test('mention 2 different channels that have the same name', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push( + { + id: 11, + name: "my channel", + public: 'public', // mentioning another channel is possible only from a public channel + }, + { + id: 12, + name: "my channel", + }, + ); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 11, + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => { + ["#", "m", "y"].forEach((char)=>{ + document.execCommand('insertText', false, char); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + }); + await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[0].click()); + await afterNextRender(() => { + ["#", "m", "y"].forEach((char)=>{ + document.execCommand('insertText', false, char); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + }); + await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[1].click()); + await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click()); + assert.containsOnce(document.body, '.o_Message_content', 'should have one message after posting it'); + assert.containsOnce( + document.querySelector(`.o_Message_content`), + `.o_channel_redirect[data-oe-id="11"][data-oe-model="mail.channel"]:contains("#my channel")`, + "message should contain the first channel mention" + ); + assert.containsOnce( + document.querySelector(`.o_Message_content`), + `.o_channel_redirect[data-oe-id="12"][data-oe-model="mail.channel"]:contains("#my channel")`, + "message should also contain the second channel mention" + ); +}); + +QUnit.test('show empty placeholder when thread contains no message', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 11 }); + await this.start(); + const threadViewer = await this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['insert', { + id: 11, + model: 'mail.channel', + }]], + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + this.createThreadViewComponent(threadViewer.threadView); + }, + message: "should wait until thread becomes loaded with messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_MessageList_empty', + "message list empty placeholder should be shown as thread does not contain any messages" + ); + assert.containsNone( + document.body, + '.o_Message', + "no message should be shown as thread does not contain any" + ); +}); + +QUnit.test('show empty placeholder when thread contains only empty messages', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message'].records.push( + { + channel_ids: [11], + id: 101, + }, + ); + await this.start(); + const threadViewer = await this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['insert', { + id: 11, + model: 'mail.channel', + }]], + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + this.createThreadViewComponent(threadViewer.threadView); + }, + message: "thread become loaded with messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_MessageList_empty', + "message list empty placeholder should be shown as thread contain only empty messages" + ); + assert.containsNone( + document.body, + '.o_Message', + "no message should be shown as thread contains only empty ones" + ); +}); + +QUnit.test('message with subtype should be displayed (and not considered as empty)', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 11 }); + this.data['mail.message.subtype'].records.push({ + description: "Task created", + id: 10, + }); + this.data['mail.message'].records.push( + { + channel_ids: [11], + id: 101, + subtype_id: 10, + }, + ); + await this.start(); + const threadViewer = await this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['insert', { + id: 11, + model: 'mail.channel', + }]], + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + this.createThreadViewComponent(threadViewer.threadView); + }, + message: "should wait until thread becomes loaded with messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "should display 1 message (message with subtype description 'task created')" + ); + assert.strictEqual( + document.body.querySelector('.o_Message_content').textContent, + "Task created", + "message should have 'Task created' (from its subtype description)" + ); +}); + +QUnit.test('[technical] message list with a full page of empty messages should show load more if there are other messages', async function (assert) { + // Technical assumptions : + // - message_fetch fetching exactly 30 messages, + // - empty messages not being displayed + // - auto-load more being triggered on scroll, not automatically when the 30 first messages are empty + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 11, + }); + for (let i = 0; i <= 30; i++) { + this.data['mail.message'].records.push({ + body: "not empty", + channel_ids: [11], + }); + } + for (let i = 0; i <= 30; i++) { + this.data['mail.message'].records.push({ + channel_ids: [11], + }); + } + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['insert', { + id: 11, + model: 'mail.channel', + }]], + }); + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => { + this.createThreadViewComponent(threadViewer.threadView, { order: 'asc' }, { isFixedSize: true }); + }, + message: "should wait until thread becomes loaded with messages", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 + ); + }, + }); + assert.containsNone( + document.body, + '.o_Message', + "No message should be shown as all 30 first messages are empty" + ); + assert.containsOnce( + document.body, + '.o_MessageList_loadMore', + "Load more button should be shown as there are more messages to show" + ); +}); + +QUnit.test('first unseen message should be directly preceded by the new message separator if there is a transient message just before it while composer is not focused [REQUIRE FOCUS]', async function (assert) { + // The goal of removing the focus is to ensure the thread is not marked as seen automatically. + // Indeed that would trigger channel_seen no matter what, which is already covered by other tests. + // The goal of this test is to cover the conditions specific to transient messages, + // and the conditions from focus would otherwise shadow them. + assert.expect(3); + + this.data['mail.channel_command'].records.push({ name: 'who' }); + // Needed partner & user to allow simulation of message reception + this.data['res.partner'].records.push({ + id: 11, + name: "Foreigner partner", + }); + this.data['res.users'].records.push({ + id: 42, + name: "Foreigner user", + partner_id: 11, + }); + this.data['mail.channel'].records = [{ + channel_type: 'channel', + id: 20, + is_pinned: true, + name: "General", + uuid: 'channel20uuid', + }]; + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel' + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + // send a command that leads to receiving a transient message + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => document.execCommand('insertText', false, "/who")); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); + + // composer is focused by default, we remove that focus + document.querySelector('.o_ComposerTextInput_textarea').blur(); + // simulate receiving a message + await afterNextRender(() => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "test", + uuid: 'channel20uuid', + }, + })); + assert.containsN( + document.body, + '.o_Message', + 2, + "should display 2 messages (the transient & the received message), after posting a command" + ); + assert.containsOnce( + document.body, + '.o_MessageList_separatorNewMessages', + "separator should be shown as a message has been received" + ); + assert.containsOnce( + document.body, + `.o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(m => m.isTransient).localId + }"] + .o_MessageList_separatorNewMessages`, + "separator should be shown just after transient message" + ); +}); + +QUnit.test('composer should be focused automatically after clicking on the send button [REQUIRE FOCUS]', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({id: 20,}); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel' + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['link', thread]], + }); + await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); + document.querySelector('.o_ComposerTextInput_textarea').focus(); + await afterNextRender(() => document.execCommand('insertText', false, "Dummy Message")); + await afterNextRender(() => { + document.querySelector('.o_Composer_buttonSend').click(); + }); + assert.hasClass( + document.querySelector('.o_Composer'), + 'o-focused', + "composer should be focused automatically after clicking on the send button" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/env/test_env.js b/addons/mail/static/src/env/test_env.js new file mode 100644 index 00000000..048bfc8d --- /dev/null +++ b/addons/mail/static/src/env/test_env.js @@ -0,0 +1,148 @@ +odoo.define('mail/static/src/env/test_env.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); + +const { Store } = owl; +const { EventBus } = owl.core; + +/** + * @param {Object} [providedEnv={}] + * @returns {Object} + */ +function addMessagingToEnv(providedEnv = {}) { + const env = Object.assign(providedEnv); + + /** + * Messaging store + */ + const store = new Store({ + env, + state: { + messagingRevNumber: 0, + }, + }); + + /** + * Registry of models. + */ + env.models = {}; + /** + * Environment keys used in messaging. + */ + Object.assign(env, { + autofetchPartnerImStatus: false, + browser: Object.assign({ + innerHeight: 1080, + innerWidth: 1920, + Notification: Object.assign({ + permission: 'denied', + async requestPermission() { + return this.permission; + }, + }, (env.browser && env.browser.Notification) || {}), + }, env.browser), + destroyMessaging() { + if (env.modelManager) { + env.modelManager.deleteAll(); + env.messaging = undefined; + } + }, + disableAnimation: true, + isMessagingInitialized() { + if (!this.messaging) { + return false; + } + return this.messaging.isInitialized; + }, + /** + * States whether the environment is in QUnit test or not. + * + * Useful to prevent some behaviour in QUnit tests, like applying + * style of attachment that uses url. + */ + isQUnitTest: true, + loadingBaseDelayDuration: providedEnv.loadingBaseDelayDuration || 0, + messaging: undefined, + messagingCreatedPromise: makeDeferred(), + messagingInitializedDeferred: makeDeferred(), + messagingBus: new EventBus(), + modelManager: undefined, + store, + }); + + return env; +} + +/** + * @param {Object} [providedEnv={}] + * @returns {Object} + */ +function addTimeControlToEnv(providedEnv = {}) { + + let env = Object.assign({}, providedEnv); + + if (!env.browser) { + env.browser = {}; + } + // list of timeout ids that have timed out. + let timedOutIds = []; + // key: timeoutId, value: func + remaining duration + const timeouts = new Map(); + Object.assign(env.browser, { + clearTimeout: id => { + timeouts.delete(id); + timedOutIds = timedOutIds.filter(i => i !== id); + }, + setTimeout: (func, duration) => { + const timeoutId = _.uniqueId('timeout_'); + const timeout = { + id: timeoutId, + isTimedOut: false, + func, + duration, + }; + timeouts.set(timeoutId, timeout); + if (duration === 0) { + timedOutIds.push(timeoutId); + timeout.isTimedOut = true; + } + return timeoutId; + }, + }); + if (!env.testUtils) { + env.testUtils = {}; + } + Object.assign(env.testUtils, { + advanceTime: async duration => { + await nextTick(); + for (const id of timeouts.keys()) { + const timeout = timeouts.get(id); + if (timeout.isTimedOut) { + continue; + } + timeout.duration = Math.max(timeout.duration - duration, 0); + if (timeout.duration === 0) { + timedOutIds.push(id); + } + } + while (timedOutIds.length > 0) { + const id = timedOutIds.shift(); + const timeout = timeouts.get(id); + timeouts.delete(id); + timeout.func(); + await nextTick(); + } + await nextTick(); + }, + }); + return env; +} + +return { + addMessagingToEnv, + addTimeControlToEnv, +}; + +}); diff --git a/addons/mail/static/src/img/_al.png b/addons/mail/static/src/img/_al.png Binary files differnew file mode 100644 index 00000000..f6843ddf --- /dev/null +++ b/addons/mail/static/src/img/_al.png diff --git a/addons/mail/static/src/img/_pinky.png b/addons/mail/static/src/img/_pinky.png Binary files differnew file mode 100644 index 00000000..69c18a32 --- /dev/null +++ b/addons/mail/static/src/img/_pinky.png diff --git a/addons/mail/static/src/img/attachment.png b/addons/mail/static/src/img/attachment.png Binary files differnew file mode 100644 index 00000000..5cc0c332 --- /dev/null +++ b/addons/mail/static/src/img/attachment.png diff --git a/addons/mail/static/src/img/checklist.png b/addons/mail/static/src/img/checklist.png Binary files differnew file mode 100644 index 00000000..d252606f --- /dev/null +++ b/addons/mail/static/src/img/checklist.png diff --git a/addons/mail/static/src/img/email_icon.png b/addons/mail/static/src/img/email_icon.png Binary files differnew file mode 100644 index 00000000..78c131c0 --- /dev/null +++ b/addons/mail/static/src/img/email_icon.png diff --git a/addons/mail/static/src/img/email_template.png b/addons/mail/static/src/img/email_template.png Binary files differnew file mode 100644 index 00000000..d679492b --- /dev/null +++ b/addons/mail/static/src/img/email_template.png diff --git a/addons/mail/static/src/img/email_template_save.png b/addons/mail/static/src/img/email_template_save.png Binary files differnew file mode 100644 index 00000000..bf29f9ab --- /dev/null +++ b/addons/mail/static/src/img/email_template_save.png diff --git a/addons/mail/static/src/img/formatting.png b/addons/mail/static/src/img/formatting.png Binary files differnew file mode 100644 index 00000000..cf45fdf1 --- /dev/null +++ b/addons/mail/static/src/img/formatting.png diff --git a/addons/mail/static/src/img/groupdefault.png b/addons/mail/static/src/img/groupdefault.png Binary files differnew file mode 100644 index 00000000..5d628e99 --- /dev/null +++ b/addons/mail/static/src/img/groupdefault.png diff --git a/addons/mail/static/src/img/odoo_o.png b/addons/mail/static/src/img/odoo_o.png Binary files differnew file mode 100644 index 00000000..d1839f6d --- /dev/null +++ b/addons/mail/static/src/img/odoo_o.png diff --git a/addons/mail/static/src/img/odoobot.png b/addons/mail/static/src/img/odoobot.png Binary files differnew file mode 100644 index 00000000..b1921736 --- /dev/null +++ b/addons/mail/static/src/img/odoobot.png diff --git a/addons/mail/static/src/img/odoobot_transparent.png b/addons/mail/static/src/img/odoobot_transparent.png Binary files differnew file mode 100644 index 00000000..e8c5a4da --- /dev/null +++ b/addons/mail/static/src/img/odoobot_transparent.png diff --git a/addons/mail/static/src/img/smiley/avatar.jpg b/addons/mail/static/src/img/smiley/avatar.jpg Binary files differnew file mode 100644 index 00000000..71687940 --- /dev/null +++ b/addons/mail/static/src/img/smiley/avatar.jpg diff --git a/addons/mail/static/src/img/smiley/green.png b/addons/mail/static/src/img/smiley/green.png Binary files differnew file mode 100644 index 00000000..0700a5ef --- /dev/null +++ b/addons/mail/static/src/img/smiley/green.png diff --git a/addons/mail/static/src/img/smiley/mailfailure.jpg b/addons/mail/static/src/img/smiley/mailfailure.jpg Binary files differnew file mode 100644 index 00000000..6f0ec91a --- /dev/null +++ b/addons/mail/static/src/img/smiley/mailfailure.jpg diff --git a/addons/mail/static/src/img/smiley/yellow.png b/addons/mail/static/src/img/smiley/yellow.png Binary files differnew file mode 100644 index 00000000..726570e5 --- /dev/null +++ b/addons/mail/static/src/img/smiley/yellow.png diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js new file mode 100644 index 00000000..ae9d914f --- /dev/null +++ b/addons/mail/static/src/js/activity.js @@ -0,0 +1,868 @@ +odoo.define('mail.Activity', function (require) { +"use strict"; + +var mailUtils = require('mail.utils'); + +var AbstractField = require('web.AbstractField'); +var BasicModel = require('web.BasicModel'); +var config = require('web.config'); +var core = require('web.core'); +var field_registry = require('web.field_registry'); +var session = require('web.session'); +var framework = require('web.framework'); +var time = require('web.time'); + +var QWeb = core.qweb; +var _t = core._t; +const _lt = core._lt; + +/** + * Fetches activities and postprocesses them. + * + * This standalone function performs an RPC, but to do so, it needs an instance + * of a widget that implements the _rpc() function. + * + * @todo i'm not very proud of the widget instance given in arguments, we should + * probably try to do it a better way in the future. + * + * @param {Widget} self a widget instance that can perform RPCs + * @param {Array} ids the ids of activities to read + * @return {Promise<Array>} resolved with the activities + */ +function _readActivities(self, ids) { + if (!ids.length) { + return Promise.resolve([]); + } + var context = self.getSession().user_context; + if (self.record && !_.isEmpty(self.record.getContext())) { + context = self.record.getContext(); + } + return self._rpc({ + model: 'mail.activity', + method: 'activity_format', + args: [ids], + context: context, + }).then(function (activities) { + // convert create_date and date_deadline to moments + _.each(activities, function (activity) { + activity.create_date = moment(time.auto_str_to_date(activity.create_date)); + activity.date_deadline = moment(time.auto_str_to_date(activity.date_deadline)); + }); + // sort activities by due date + activities = _.sortBy(activities, 'date_deadline'); + return activities; + }); +} + +BasicModel.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetches the activities displayed by the activity field widget in form + * views. + * + * @private + * @param {Object} record - an element from the localData + * @param {string} fieldName + * @return {Promise<Array>} resolved with the activities + */ + _fetchSpecialActivity: function (record, fieldName) { + var localID = (record._changes && fieldName in record._changes) ? + record._changes[fieldName] : + record.data[fieldName]; + return _readActivities(this, this.localData[localID].res_ids); + }, +}); + +/** + * Set the 'label_delay' entry in activity data according to the deadline date + * + * @param {Array} activities list of activity Object + * @return {Array} : list of modified activity Object + */ +var setDelayLabel = function (activities) { + var today = moment().startOf('day'); + _.each(activities, function (activity) { + var toDisplay = ''; + var diff = activity.date_deadline.diff(today, 'days', true); // true means no rounding + if (diff === 0) { + toDisplay = _t("Today"); + } else { + if (diff < 0) { // overdue + if (diff === -1) { + toDisplay = _t("Yesterday"); + } else { + toDisplay = _.str.sprintf(_t("%d days overdue"), Math.abs(diff)); + } + } else { // due + if (diff === 1) { + toDisplay = _t("Tomorrow"); + } else { + toDisplay = _.str.sprintf(_t("Due in %d days"), Math.abs(diff)); + } + } + } + activity.label_delay = toDisplay; + }); + return activities; +}; + +/** + * Set the file upload identifier for 'upload_file' type activities + * + * @param {Array} activities list of activity Object + * @return {Array} : list of modified activity Object + */ +var setFileUploadID = function (activities) { + _.each(activities, function (activity) { + if (activity.activity_category === 'upload_file') { + activity.fileuploadID = _.uniqueId('o_fileupload'); + } + }); + return activities; +}; + +var BasicActivity = AbstractField.extend({ + events: { + 'click .o_edit_activity': '_onEditActivity', + 'change input.o_input_file': '_onFileChanged', + 'click .o_mark_as_done': '_onMarkActivityDone', + 'click .o_mark_as_done_upload_file': '_onMarkActivityDoneUploadFile', + 'click .o_activity_template_preview': '_onPreviewMailTemplate', + 'click .o_schedule_activity': '_onScheduleActivity', + 'click .o_activity_template_send': '_onSendMailTemplate', + 'click .o_unlink_activity': '_onUnlinkActivity', + }, + init: function () { + this._super.apply(this, arguments); + this._draftFeedback = {}; + }, + + //------------------------------------------------------------ + // Public + //------------------------------------------------------------ + + /** + * @param {integer} previousActivityTypeID + * @return {Promise} + */ + scheduleActivity: function () { + var callback = this._reload.bind(this, { activity: true, thread: true }); + return this._openActivityForm(false, callback); + }, + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * Send a feedback and reload page in order to mark activity as done + * + * @private + * @param {Object} params + * @param {integer} params.activityID + * @param {integer[]} params.attachmentIds + * @param {string} params.feedback + * + * @return {$.Promise} + */ + _markActivityDone: function (params) { + var activityID = params.activityID; + var feedback = params.feedback || false; + var attachmentIds = params.attachmentIds || []; + + return this._sendActivityFeedback(activityID, feedback, attachmentIds) + .then(this._reload.bind(this, { activity: true, thread: true })); + }, + /** + * Send a feedback and proposes to schedule next activity + * previousActivityTypeID will be given to new activity to propose activity + * type based on recommended next activity + * + * @private + * @param {Object} params + * @param {integer} params.activityID + * @param {string} params.feedback + */ + _markActivityDoneAndScheduleNext: function (params) { + var activityID = params.activityID; + var feedback = params.feedback; + var self = this; + this._rpc({ + model: 'mail.activity', + method: 'action_feedback_schedule_next', + args: [[activityID]], + kwargs: {feedback: feedback}, + context: this.record.getContext(), + }).then( + function (rslt_action) { + if (rslt_action) { + self.do_action(rslt_action, { + on_close: function () { + self.trigger_up('reload', { keepChanges: true }); + }, + }); + } else { + self.trigger_up('reload', { keepChanges: true }); + } + } + ); + }, + /** + * @private + * @param {integer} id + * @param {function} callback + * @return {Promise} + */ + _openActivityForm: function (id, callback) { + var action = { + type: 'ir.actions.act_window', + name: _t("Schedule Activity"), + res_model: 'mail.activity', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.res_id, + default_res_model: this.model, + }, + res_id: id || false, + }; + return this.do_action(action, { on_close: callback }); + }, + /** + * @private + * @param {integer} activityID + * @param {string} feedback + * @param {integer[]} attachmentIds + * @return {Promise} + */ + _sendActivityFeedback: function (activityID, feedback, attachmentIds) { + return this._rpc({ + model: 'mail.activity', + method: 'action_feedback', + args: [[activityID]], + kwargs: { + feedback: feedback, + attachment_ids: attachmentIds || [], + }, + context: this.record.getContext(), + }); + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * @private + * @param {Object[]} activities + */ + _bindOnUploadAction: function (activities) { + var self = this; + _.each(activities, function (activity) { + if (activity.fileuploadID) { + $(window).on(activity.fileuploadID, function () { + framework.unblockUI(); + // find the button clicked and display the feedback popup on it + var files = Array.prototype.slice.call(arguments, 1); + self._markActivityDone({ + activityID: activity.id, + attachmentIds: _.pluck(files, 'id') + }).then(function () { + self.trigger_up('reload', { keepChanges: true }); + }); + }); + } + }); + }, + /** Binds a focusout handler on a bootstrap popover + * Useful to do some operations on the popover's HTML, + * like keeping the user's input for the feedback + * @param {JQuery} $popover_el: the element on which + * the popover() method has been called + */ + _bindPopoverFocusout: function ($popover_el) { + var self = this; + // Retrieve the actual popover's HTML + var $popover = $($popover_el.data("bs.popover").tip); + var activityID = $popover_el.data('activity-id'); + $popover.off('focusout'); + $popover.focusout(function (e) { + // outside click of popover hide the popover + // e.relatedTarget is the element receiving the focus + if (!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) { + self._draftFeedback[activityID] = $popover.find('#activity_feedback').val(); + $popover.popover('hide'); + } + }); + }, + + /** + * @private + * @param {MouseEvent} ev + * @returns {Promise} + */ + _onEditActivity: function (ev) { + ev.preventDefault(); + var activityID = $(ev.currentTarget).data('activity-id'); + return this._openActivityForm(activityID, this._reload.bind(this, { activity: true, thread: true })); + }, + /** + * @private + * @param {FormEvent} ev + */ + _onFileChanged: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var $form = $(ev.currentTarget).closest('form'); + $form.submit(); + framework.blockUI(); + }, + /** + * Called when marking an activity as done + * + * It lets the current user write a feedback in a popup menu. + * After writing the feedback and confirm mark as done + * is sent, it marks this activity as done for good with the feedback linked + * to it. + * + * @private + * @param {MouseEvent} ev + */ + _onMarkActivityDone: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var self = this; + var $markDoneBtn = $(ev.currentTarget); + var activityID = $markDoneBtn.data('activity-id'); + var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id') || false; + var forceNextActivity = $markDoneBtn.data('force-next-activity'); + + if ($markDoneBtn.data('toggle') === 'collapse') { + var $actLi = $markDoneBtn.parents('.o_log_activity'); + var $panel = self.$('#o_activity_form_' + activityID); + + if (!$panel.data('bs.collapse')) { + var $form = $(QWeb.render('mail.activity_feedback_form', { + previous_activity_type_id: previousActivityTypeID, + force_next: forceNextActivity + })); + $panel.append($form); + self._onMarkActivityDoneActions($markDoneBtn, $form, activityID); + + // Close and reset any other open panels + _.each($panel.siblings('.o_activity_form'), function (el) { + if ($(el).data('bs.collapse')) { + $(el).empty().collapse('dispose').removeClass('show'); + } + }); + + // Scroll to selected activity + $markDoneBtn.parents('.o_activity_log_container').scrollTo($actLi.position().top, 100); + } + + // Empty and reset panel on close + $panel.on('hidden.bs.collapse', function () { + if ($panel.data('bs.collapse')) { + $actLi.removeClass('o_activity_selected'); + $panel.collapse('dispose'); + $panel.empty(); + } + }); + + this.$('.o_activity_selected').removeClass('o_activity_selected'); + $actLi.toggleClass('o_activity_selected'); + $panel.collapse('toggle'); + + } else if (!$markDoneBtn.data('bs.popover')) { + $markDoneBtn.popover({ + template: $(Popover.Default.template).addClass('o_mail_activity_feedback')[0].outerHTML, // Ugly but cannot find another way + container: $markDoneBtn, + title: _t("Feedback"), + html: true, + trigger: 'manual', + placement: 'right', // FIXME: this should work, maybe a bug in the popper lib + content: function () { + var $popover = $(QWeb.render('mail.activity_feedback_form', { + previous_activity_type_id: previousActivityTypeID, + force_next: forceNextActivity + })); + self._onMarkActivityDoneActions($markDoneBtn, $popover, activityID); + return $popover; + }, + }).on('shown.bs.popover', function () { + var $popover = $($(this).data("bs.popover").tip); + $(".o_mail_activity_feedback.popover").not($popover).popover("hide"); + $popover.addClass('o_mail_activity_feedback').attr('tabindex', 0); + $popover.find('#activity_feedback').focus(); + self._bindPopoverFocusout($(this)); + }).popover('show'); + } else { + var popover = $markDoneBtn.data('bs.popover'); + if ($('#' + popover.tip.id).length === 0) { + popover.show(); + } + } + }, + /** + * Bind all necessary actions to the 'mark as done' form + * + * @private + * @param {Object} $form + * @param {integer} activityID + */ + _onMarkActivityDoneActions: function ($btn, $form, activityID) { + var self = this; + $form.find('#activity_feedback').val(self._draftFeedback[activityID]); + $form.on('click', '.o_activity_popover_done', function (ev) { + ev.stopPropagation(); + self._markActivityDone({ + activityID: activityID, + feedback: $form.find('#activity_feedback').val(), + }); + }); + $form.on('click', '.o_activity_popover_done_next', function (ev) { + ev.stopPropagation(); + self._markActivityDoneAndScheduleNext({ + activityID: activityID, + feedback: $form.find('#activity_feedback').val(), + }); + }); + $form.on('click', '.o_activity_popover_discard', function (ev) { + ev.stopPropagation(); + if ($btn.data('bs.popover')) { + $btn.popover('hide'); + } else if ($btn.data('toggle') === 'collapse') { + self.$('#o_activity_form_' + activityID).collapse('hide'); + } + }); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onMarkActivityDoneUploadFile: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + var fileuploadID = $(ev.currentTarget).data('fileupload-id'); + var $input = this.$("[target='" + fileuploadID + "'] > input.o_input_file"); + $input.click(); + }, + /** + * @private + * @param {MouseEvent} ev + * @returns {Promise} + */ + _onPreviewMailTemplate: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var self = this; + var templateID = $(ev.currentTarget).data('template-id'); + var action = { + name: _t('Compose Email'), + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.res_id, + default_model: this.model, + default_use_template: true, + default_template_id: templateID, + force_email: true, + }, + }; + return this.do_action(action, { on_close: function () { + self.trigger_up('reload', { keepChanges: true }); + } }); + }, + /** + * @private + * @param {MouseEvent} ev + * @returns {Promise} + */ + _onSendMailTemplate: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + var templateID = $(ev.currentTarget).data('template-id'); + return this._rpc({ + model: this.model, + method: 'activity_send_mail', + args: [[this.res_id], templateID], + }) + .then(this._reload.bind(this, {activity: true, thread: true, followers: true})); + }, + /** + * @private + * @param {MouseEvent} ev + * @returns {Promise} + */ + _onScheduleActivity: function (ev) { + ev.preventDefault(); + return this._openActivityForm(false, this._reload.bind(this)); + }, + + /** + * @private + * @param {MouseEvent} ev + * @param {Object} options + * @returns {Promise} + */ + _onUnlinkActivity: function (ev, options) { + ev.preventDefault(); + var activityID = $(ev.currentTarget).data('activity-id'); + options = _.defaults(options || {}, { + model: 'mail.activity', + args: [[activityID]], + }); + return this._rpc({ + model: options.model, + method: 'unlink', + args: options.args, + }) + .then(this._reload.bind(this, {activity: true})); + }, + /** + * Unbind event triggered when a file is uploaded. + * + * @private + * @param {Array} activities: list of activity to unbind + */ + _unbindOnUploadAction: function (activities) { + _.each(activities, function (activity) { + if (activity.fileuploadID) { + $(window).off(activity.fileuploadID); + } + }); + }, +}); + +// ----------------------------------------------------------------------------- +// Activities Widget for Form views ('mail_activity' widget) +// ----------------------------------------------------------------------------- +// FIXME seems to still be needed in some cases like systray +var Activity = BasicActivity.extend({ + className: 'o_mail_activity', + events: _.extend({}, BasicActivity.prototype.events, { + 'click a': '_onClickRedirect', + }), + specialData: '_fetchSpecialActivity', + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this._activities = this.record.specialData[this.name]; + }, + /** + * @override + */ + destroy: function () { + this._unbindOnUploadAction(); + return this._super.apply(this, arguments); + }, + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + /** + * @private + * @param {Object} fieldsToReload + */ + _reload: function (fieldsToReload) { + this.trigger_up('reload_mail_fields', fieldsToReload); + }, + /** + * @override + * @private + */ + _render: function () { + _.each(this._activities, function (activity) { + var note = mailUtils.parseAndTransform(activity.note || '', mailUtils.inline); + var is_blank = (/^\s*$/).test(note); + if (!is_blank) { + activity.note = mailUtils.parseAndTransform(activity.note, mailUtils.addLink); + } else { + activity.note = ''; + } + }); + var activities = setFileUploadID(setDelayLabel(this._activities)); + if (activities.length) { + var nbActivities = _.countBy(activities, 'state'); + this.$el.html(QWeb.render('mail.activity_items', { + uid: session.uid, + activities: activities, + nbPlannedActivities: nbActivities.planned, + nbTodayActivities: nbActivities.today, + nbOverdueActivities: nbActivities.overdue, + dateFormat: time.getLangDateFormat(), + datetimeFormat: time.getLangDatetimeFormat(), + session: session, + widget: this, + })); + this._bindOnUploadAction(this._activities); + } else { + this._unbindOnUploadAction(this._activities); + this.$el.empty(); + } + }, + /** + * @override + * @private + * @param {Object} record + */ + _reset: function (record) { + this._super.apply(this, arguments); + this._activities = this.record.specialData[this.name]; + // the mail widgets being persistent, one need to update the res_id on reset + this.res_id = record.res_id; + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRedirect: function (ev) { + var id = $(ev.currentTarget).data('oe-id'); + if (id) { + ev.preventDefault(); + var model = $(ev.currentTarget).data('oe-model'); + this.trigger_up('redirect', { + res_id: id, + res_model: model, + }); + } + }, + +}); + +// ----------------------------------------------------------------------------- +// Activities Widget for Kanban views ('kanban_activity' widget) +// ----------------------------------------------------------------------------- +var KanbanActivity = BasicActivity.extend({ + template: 'mail.KanbanActivity', + events: _.extend({}, BasicActivity.prototype.events, { + 'show.bs.dropdown': '_onDropdownShow', + }), + fieldDependencies: _.extend({}, BasicActivity.prototype.fieldDependencies, { + activity_exception_decoration: {type: 'selection'}, + activity_exception_icon: {type: 'char'}, + activity_state: {type: 'selection'}, + }), + + /** + * @override + */ + init: function (parent, name, record) { + this._super.apply(this, arguments); + var selection = {}; + _.each(record.fields.activity_state.selection, function (value) { + selection[value[0]] = value[1]; + }); + this.selection = selection; + this._setState(record); + }, + /** + * @override + */ + destroy: function () { + this._unbindOnUploadAction(); + return this._super.apply(this, arguments); + }, + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * @private + */ + _reload: function () { + this.trigger_up('reload', { db_id: this.record_id, keepChanges: true }); + }, + /** + * @override + * @private + */ + _render: function () { + // span classes need to be updated manually because the template cannot + // be re-rendered eaasily (because of the dropdown state) + const spanClasses = ['fa', 'fa-lg', 'fa-fw']; + spanClasses.push('o_activity_color_' + (this.activityState || 'default')); + if (this.recordData.activity_exception_decoration) { + spanClasses.push('text-' + this.recordData.activity_exception_decoration); + spanClasses.push(this.recordData.activity_exception_icon); + } else { + spanClasses.push('fa-clock-o'); + } + this.$('.o_activity_btn > span').removeClass().addClass(spanClasses.join(' ')); + + if (this.$el.hasClass('show')) { + // note: this part of the rendering might be asynchronous + this._renderDropdown(); + } + }, + /** + * @private + */ + _renderDropdown: function () { + var self = this; + this.$('.o_activity') + .toggleClass('dropdown-menu-right', config.device.isMobile) + .html(QWeb.render('mail.KanbanActivityLoading')); + return _readActivities(this, this.value.res_ids).then(function (activities) { + activities = setFileUploadID(activities); + self.$('.o_activity').html(QWeb.render('mail.KanbanActivityDropdown', { + selection: self.selection, + records: _.groupBy(setDelayLabel(activities), 'state'), + session: session, + widget: self, + })); + self._bindOnUploadAction(activities); + }); + }, + /** + * @override + * @private + * @param {Object} record + */ + _reset: function (record) { + this._super.apply(this, arguments); + this._setState(record); + }, + /** + * @private + * @param {Object} record + */ + _setState: function (record) { + this.record_id = record.id; + this.activityState = this.recordData.activity_state; + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * @private + */ + _onDropdownShow: function () { + this._renderDropdown(); + }, +}); + +// ----------------------------------------------------------------------------- +// Activities Widget for List views ('list_activity' widget) +// ----------------------------------------------------------------------------- +const ListActivity = KanbanActivity.extend({ + template: 'mail.ListActivity', + events: Object.assign({}, KanbanActivity.prototype.events, { + 'click .dropdown-menu.o_activity': '_onDropdownClicked', + }), + fieldDependencies: _.extend({}, KanbanActivity.prototype.fieldDependencies, { + activity_summary: {type: 'char'}, + activity_type_id: {type: 'many2one', relation: 'mail.activity.type'}, + activity_type_icon: {type: 'char'}, + }), + label: _lt('Next Activity'), + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: async function () { + await this._super(...arguments); + // set the 'special_click' prop on the activity icon to prevent from + // opening the record when the user clicks on it (as it opens the + // activity dropdown instead) + this.$('.o_activity_btn > span').prop('special_click', true); + if (this.value.count) { + let text; + if (this.recordData.activity_exception_decoration) { + text = _t('Warning'); + } else { + text = this.recordData.activity_summary || + this.recordData.activity_type_id.data.display_name; + } + this.$('.o_activity_summary').text(text); + } + if (this.recordData.activity_type_icon) { + this.el.querySelector('.o_activity_btn > span').classList.replace('fa-clock-o', this.recordData.activity_type_icon); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * As we are in a list view, we don't want clicks inside the activity + * dropdown to open the record in a form view. + * + * @private + * @param {MouseEvent} ev + */ + _onDropdownClicked: function (ev) { + ev.stopPropagation(); + }, +}); + +// ----------------------------------------------------------------------------- +// Activity Exception Widget to display Exception icon ('activity_exception' widget) +// ----------------------------------------------------------------------------- + +var ActivityException = AbstractField.extend({ + noLabel: true, + fieldDependencies: _.extend({}, AbstractField.prototype.fieldDependencies, { + activity_exception_icon: {type: 'char'} + }), + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * There is no edit mode for this widget, the icon is always readonly. + * + * @override + * @private + */ + _renderEdit: function () { + return this._renderReadonly(); + }, + + /** + * Displays the exception icon if there is one. + * + * @override + * @private + */ + _renderReadonly: function () { + this.$el.empty(); + if (this.value) { + this.$el.attr({ + title: _t('This record has an exception activity.'), + class: "pull-right mt-1 text-" + this.value + " fa " + this.recordData.activity_exception_icon + }); + } + } +}); + +field_registry + .add('kanban_activity', KanbanActivity) + .add('list_activity', ListActivity) + .add('activity_exception', ActivityException); + +return Activity; + +}); diff --git a/addons/mail/static/src/js/basic_view.js b/addons/mail/static/src/js/basic_view.js new file mode 100644 index 00000000..4c5c9a12 --- /dev/null +++ b/addons/mail/static/src/js/basic_view.js @@ -0,0 +1,68 @@ +odoo.define('mail.BasicView', function (require) { +"use strict"; + +const BasicView = require('web.BasicView'); + +const mailWidgets = ['kanban_activity']; + +BasicView.include({ + init: function () { + this._super.apply(this, arguments); + const post_refresh = this._getFieldOption('message_ids', 'post_refresh', false); + const followers_post_refresh = this._getFieldOption('message_follower_ids', 'post_refresh', false); + this.chatterFields = { + hasActivityIds: this._hasField('activity_ids'), + hasMessageFollowerIds: this._hasField('message_follower_ids'), + hasMessageIds: this._hasField('message_ids'), + hasRecordReloadOnAttachmentsChanged: post_refresh === 'always', + hasRecordReloadOnMessagePosted: !!post_refresh, + hasRecordReloadOnFollowersUpdate: !!followers_post_refresh, + isAttachmentBoxVisibleInitially: ( + this._getFieldOption('message_ids', 'open_attachments', false) || + this._getFieldOption('message_follower_ids', 'open_attachments', false) + ), + }; + const fieldsInfo = this.fieldsInfo[this.viewType]; + this.rendererParams.chatterFields = this.chatterFields; + + // LEGACY for widget kanban_activity + this.mailFields = {}; + for (const fieldName in fieldsInfo) { + const fieldInfo = fieldsInfo[fieldName]; + if (_.contains(mailWidgets, fieldInfo.widget)) { + this.mailFields[fieldInfo.widget] = fieldName; + fieldInfo.__no_fetch = true; + } + } + this.rendererParams.activeActions = this.controllerParams.activeActions; + this.rendererParams.mailFields = this.mailFields; + }, + /** + * Gets the option value of a field if present. + * + * @private + * @param {string} fieldName the desired field name + * @param {string} optionName the desired option name + * @param {*} defaultValue the default value if option or field is not found. + * @returns {*} + */ + _getFieldOption(fieldName, optionName, defaultValue) { + const field = this.fieldsInfo[this.viewType][fieldName]; + if (field && field.options && field.options[optionName] !== undefined) { + return field.options[optionName]; + } + return defaultValue; + }, + /** + * Checks whether the view has a given field. + * + * @private + * @param {string} fieldName the desired field name + * @returns {boolean} + */ + _hasField(fieldName) { + return !!this.fieldsInfo[this.viewType][fieldName]; + }, +}); + +}); diff --git a/addons/mail/static/src/js/core/translation.js b/addons/mail/static/src/js/core/translation.js new file mode 100644 index 00000000..faecafaf --- /dev/null +++ b/addons/mail/static/src/js/core/translation.js @@ -0,0 +1,28 @@ +odoo.define('mail/static/src/js/core/translation.js', function (require) { +'use strict'; + +const { TranslationDataBase } = require('web.translation'); + +const { Component } = owl; + +TranslationDataBase.include({ + /** + * @override + */ + set_bundle() { + const res = this._super(...arguments); + if (Component.env.messaging) { + // Update messaging locale whenever the translation bundle changes. + // In particular if messaging is created before the end of the + // `load_translations` RPC, the default values have to be + // updated by the received ones. + Component.env.messaging.locale.update({ + language: this.parameters.code, + textDirection: this.parameters.direction, + }); + } + return res; + }, +}); + +}); diff --git a/addons/mail/static/src/js/custom_filter_item.js b/addons/mail/static/src/js/custom_filter_item.js new file mode 100644 index 00000000..abe15eda --- /dev/null +++ b/addons/mail/static/src/js/custom_filter_item.js @@ -0,0 +1,21 @@ +odoo.define('mail.CustomFilterItem', function (require) { + "use strict"; + + const CustomFilterItem = require('web.CustomFilterItem'); + + CustomFilterItem.patch('mail.CustomFilterItem', T => class extends T { + + /** + * With the `mail` module installed, we want to filter out some of the + * available fields in 'Add custom filter' menu (@see CustomFilterItem). + * @override + */ + _validateField(field) { + return super._validateField(...arguments) && + field.relation !== 'mail.message' && + field.name !== 'message_ids'; + } + }); + + return CustomFilterItem; +}); diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js new file mode 100644 index 00000000..b46aea30 --- /dev/null +++ b/addons/mail/static/src/js/document_viewer.js @@ -0,0 +1,396 @@ +odoo.define('mail.DocumentViewer', function (require) { +"use strict"; + +var core = require('web.core'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +var SCROLL_ZOOM_STEP = 0.1; +var ZOOM_STEP = 0.5; + +/** + * This widget is deprecated, and should instead use AttachmentViewer component. + * @see `mail/static/src/components/attachment_viewer/attachment_viewer.js` + * TODO: remove this widget when it's not longer used + * + * @deprecated + */ +var DocumentViewer = Widget.extend({ + template: "DocumentViewer", + events: { + 'click .o_download_btn': '_onDownload', + 'click .o_viewer_img': '_onImageClicked', + 'click .o_viewer_video': '_onVideoClicked', + 'click .move_next': '_onNext', + 'click .move_previous': '_onPrevious', + 'click .o_rotate': '_onRotate', + 'click .o_zoom_in': '_onZoomIn', + 'click .o_zoom_out': '_onZoomOut', + 'click .o_zoom_reset': '_onZoomReset', + 'click .o_close_btn, .o_viewer_img_wrapper': '_onClose', + 'click .o_print_btn': '_onPrint', + 'DOMMouseScroll .o_viewer_content': '_onScroll', // Firefox + 'mousewheel .o_viewer_content': '_onScroll', // Chrome, Safari, IE + 'keydown': '_onKeydown', + 'keyup': '_onKeyUp', + 'mousedown .o_viewer_img': '_onStartDrag', + 'mousemove .o_viewer_content': '_onDrag', + 'mouseup .o_viewer_content': '_onEndDrag' + }, + /** + * The documentViewer takes an array of objects describing attachments in + * argument, and the ID of an active attachment (the one to display first). + * Documents that are not of type image or video are filtered out. + * + * @override + * @param {Array<Object>} attachments list of attachments + * @param {integer} activeAttachmentID + */ + init: function (parent, attachments, activeAttachmentID) { + this._super.apply(this, arguments); + this.attachment = _.filter(attachments, function (attachment) { + var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)"); + if (match) { + attachment.fileType = match[1]; + if (match[1].match("(.png|.jpg|.gif)")) { + attachment.fileType = 'image'; + } + if (match[1] === 'youtu') { + var youtube_array = attachment.url.split('/'); + var youtube_token = youtube_array[youtube_array.length-1]; + if (youtube_token.indexOf('watch') !== -1) { + youtube_token = youtube_token.split('v=')[1]; + var amp = youtube_token.indexOf('&') + if (amp !== -1){ + youtube_token = youtube_token.substring(0, amp); + } + } + attachment.youtube = youtube_token; + } + return true; + } + }); + this.activeAttachment = _.findWhere(attachments, {id: activeAttachmentID}); + this.modelName = 'ir.attachment'; + this._reset(); + }, + /** + * Open a modal displaying the active attachment + * @override + */ + start: function () { + this.$el.modal('show'); + this.$el.on('hidden.bs.modal', _.bind(this._onDestroy, this)); + this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this)); + this.$('[data-toggle="tooltip"]').tooltip({delay: 0}); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + if (this.isDestroyed()) { + return; + } + this.$el.modal('hide'); + this.$el.remove(); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------------- + + /** + * @private + */ + _next: function () { + var index = _.findIndex(this.attachment, this.activeAttachment); + index = (index + 1) % this.attachment.length; + this.activeAttachment = this.attachment[index]; + this._updateContent(); + }, + /** + * @private + */ + _previous: function () { + var index = _.findIndex(this.attachment, this.activeAttachment); + index = index === 0 ? this.attachment.length - 1 : index - 1; + this.activeAttachment = this.attachment[index]; + this._updateContent(); + }, + /** + * @private + */ + _reset: function () { + this.scale = 1; + this.dragStartX = this.dragstopX = 0; + this.dragStartY = this.dragstopY = 0; + }, + /** + * Render the active attachment + * + * @private + */ + _updateContent: function () { + this.$('.o_viewer_content').html(QWeb.render('DocumentViewer.Content', { + widget: this + })); + this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this)); + this.$('[data-toggle="tooltip"]').tooltip({delay: 0}); + this._reset(); + }, + /** + * Get CSS transform property based on scale and angle + * + * @private + * @param {float} scale + * @param {float} angle + */ + _getTransform: function(scale, angle) { + return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)'; + }, + /** + * Rotate image clockwise by provided angle + * + * @private + * @param {float} angle + */ + _rotate: function (angle) { + this._reset(); + var new_angle = (this.angle || 0) + angle; + this.$('.o_viewer_img').css('transform', this._getTransform(this.scale, new_angle)); + this.$('.o_viewer_img').css('max-width', new_angle % 180 !== 0 ? $(document).height() : '100%'); + this.$('.o_viewer_img').css('max-height', new_angle % 180 !== 0 ? $(document).width() : '100%'); + this.angle = new_angle; + }, + /** + * Zoom in/out image by provided scale + * + * @private + * @param {integer} scale + */ + _zoom: function (scale) { + if (scale > 0.5) { + this.$('.o_viewer_img').css('transform', this._getTransform(scale, this.angle || 0)); + this.scale = scale; + } + this.$('.o_zoom_reset').add('.o_zoom_out').toggleClass('disabled', scale === 1); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} e + */ + _onClose: function (e) { + e.preventDefault(); + this.destroy(); + }, + /** + * When popup close complete destroyed modal even DOM footprint too + * + * @private + */ + _onDestroy: function () { + this.destroy(); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onDownload: function (e) { + e.preventDefault(); + window.location = '/web/content/' + this.modelName + '/' + this.activeAttachment.id + '/' + 'datas' + '?download=true'; + }, + /** + * @private + * @param {MouseEvent} e + */ + _onDrag: function (e) { + e.preventDefault(); + if (this.enableDrag) { + var $image = this.$('.o_viewer_img'); + var $zoomer = this.$('.o_viewer_zoomer'); + var top = $image.prop('offsetHeight') * this.scale > $zoomer.height() ? e.clientY - this.dragStartY : 0; + var left = $image.prop('offsetWidth') * this.scale > $zoomer.width() ? e.clientX - this.dragStartX : 0; + $zoomer.css("transform", "translate3d("+ left +"px, " + top + "px, 0)"); + $image.css('cursor', 'move'); + } + }, + /** + * @private + * @param {MouseEvent} e + */ + _onEndDrag: function (e) { + e.preventDefault(); + if (this.enableDrag) { + this.enableDrag = false; + this.dragstopX = e.clientX - this.dragStartX; + this.dragstopY = e.clientY - this.dragStartY; + this.$('.o_viewer_img').css('cursor', ''); + } + }, + /** + * On click of image do not close modal so stop event propagation + * + * @private + * @param {MouseEvent} e + */ + _onImageClicked: function (e) { + e.stopPropagation(); + }, + /** + * Remove loading indicator when image loaded + * @private + */ + _onImageLoaded: function () { + this.$('.o_loading_img').hide(); + }, + /** + * Move next previous attachment on keyboard right left key + * + * @private + * @param {KeyEvent} e + */ + _onKeydown: function (e){ + switch (e.which) { + case $.ui.keyCode.RIGHT: + e.preventDefault(); + this._next(); + break; + case $.ui.keyCode.LEFT: + e.preventDefault(); + this._previous(); + break; + } + }, + /** + * Close popup on ESCAPE keyup + * + * @private + * @param {KeyEvent} e + */ + _onKeyUp: function (e) { + switch (e.which) { + case $.ui.keyCode.ESCAPE: + e.preventDefault(); + this._onClose(e); + break; + } + }, + /** + * @private + * @param {MouseEvent} e + */ + _onNext: function (e) { + e.preventDefault(); + this._next(); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onPrevious: function (e) { + e.preventDefault(); + this._previous(); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onPrint: function (e) { + e.preventDefault(); + var src = this.$('.o_viewer_img').prop('src'); + var script = QWeb.render('PrintImage', { + src: src + }); + var printWindow = window.open('about:blank', "_new"); + printWindow.document.open(); + printWindow.document.write(script); + printWindow.document.close(); + }, + /** + * Zoom image on scroll + * + * @private + * @param {MouseEvent} e + */ + _onScroll: function (e) { + var scale; + if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) { + scale = this.scale + SCROLL_ZOOM_STEP; + this._zoom(scale); + } else { + scale = this.scale - SCROLL_ZOOM_STEP; + this._zoom(scale); + } + }, + /** + * @private + * @param {MouseEvent} e + */ + _onStartDrag: function (e) { + e.preventDefault(); + this.enableDrag = true; + this.dragStartX = e.clientX - (this.dragstopX || 0); + this.dragStartY = e.clientY - (this.dragstopY || 0); + }, + /** + * On click of video do not close modal so stop event propagation + * and provide play/pause the video instead of quitting it + * + * @private + * @param {MouseEvent} e + */ + _onVideoClicked: function (e) { + e.stopPropagation(); + var videoElement = e.target; + if (videoElement.paused) { + videoElement.play(); + } else { + videoElement.pause(); + } + }, + /** + * @private + * @param {MouseEvent} e + */ + _onRotate: function (e) { + e.preventDefault(); + this._rotate(90); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onZoomIn: function (e) { + e.preventDefault(); + var scale = this.scale + ZOOM_STEP; + this._zoom(scale); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onZoomOut: function (e) { + e.preventDefault(); + var scale = this.scale - ZOOM_STEP; + this._zoom(scale); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onZoomReset: function (e) { + e.preventDefault(); + this.$('.o_viewer_zoomer').css("transform", ""); + this._zoom(1); + }, +}); +return DocumentViewer; +}); diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js new file mode 100644 index 00000000..139bcdae --- /dev/null +++ b/addons/mail/static/src/js/emojis.js @@ -0,0 +1,155 @@ +odoo.define('mail.emojis', function (require) { +"use strict"; + +/** + * This module exports the list of all available emojis on the client side. + * An emoji object has the following properties: + * + * - {string[]} sources: the character representations of the emoji + * - {string} unicode: the unicode representation of the emoji + * - {string} description: the description of the emoji + */ + +/** + * This data represent all the available emojis that are supported on the web + * client: + * + * - key: this is the source representation of an emoji, i.e. its "character" + * representation. This is a string that can be easily typed by the + * user and then translated to its unicode representation (see value) + * - value: this is the unicode representation of an emoji, i.e. its "true" + * representation in the system. + */ +var data = { + ":)": "😊", + ":-)": "😊", // alternative (alt.) + "=)": "😊", // alt. + ":]": "😊", // alt. + ":D": "😃", + ":-D": "😃", // alt. + "=D": "😃", // alt. + "xD": "😆", + "XD": "😆", // alt. + "x'D": "😂", + ";)": "😉", + ";-)": "😉", // alt. + "B)": "😎", + "8)": "😎", // alt. + "B-)": "😎", // alt. + "8-)": "😎", // alt. + ";p": "😜", + ";P": "😜", // alt. + ":p": "😋", + ":P": "😋", // alt. + ":-p": "😋", // alt. + ":-P": "😋", // alt. + "=P": "😋", // alt. + "xp": "😝", + "xP": "😝", // alt. + "o_o": "😳", + ":|": "😐", + ":-|": "😐", // alt. + ":/": "😕", // alt. + ":-/": "😕", // alt. + ":(": "😞", + ":@": "😱", + ":O": "😲", + ":-O": "😲", // alt. + ":o": "😲", // alt. + ":-o": "😲", // alt. + ":'o": "😨", + "3:(": "😠", + ">:(": "😠", // alt. + "3:": "😠", // alt. + "3:)": "😈", + ">:)": "😈", // alt. + ":*": "😘", + ":-*": "😘", // alt. + "o:)": "😇", + ":'(": "😢", + ":'-(": "😭", + ":\"(": "😭", // alt. + "<3": "❤️", + "<3": "❤️", + ":heart": "❤️", // alt. + "</3": "💔", + "</3": "💔", + ":heart_eyes": "😍", + ":turban": "👳", + ":+1": "👍", + ":-1": "👎", + ":ok": "👌", + ":poop": "💩", + ":no_see": "🙈", + ":no_hear": "🙉", + ":no_speak": "🙊", + ":bug": "🐞", + ":kitten": "😺", + ":bear": "🐻", + ":snail": "🐌", + ":boar": "🐗", + ":clover": "🍀", + ":sunflower": "🌹", + ":fire": "🔥", + ":sun": "☀️", + ":partly_sunny:": "⛅️", + ":rainbow": "🌈", + ":cloud": "☁️", + ":zap": "⚡️", + ":star": "⭐️", + ":cookie": "🍪", + ":pizza": "🍕", + ":hamburger": "🍔", + ":fries": "🍟", + ":cake": "🎂", + ":cake_part": "🍰", + ":coffee": "☕️", + ":banana": "🍌", + ":sushi": "🍣", + ":rice_ball": "🍙", + ":beer": "🍺", + ":wine": "🍷", + ":cocktail": "🍸", + ":tropical": "🍹", + ":beers": "🍻", + ":ghost": "👻", + ":skull": "💀", + ":et": "👽", + ":alien": "👽", // alt. + ":party": "🎉", + ":trophy": "🏆", + ":key": "🔑", + ":pin": "📌", + ":postal_horn": "📯", + ":music": "🎵", + ":trumpet": "🎺", + ":guitar": "🎸", + ":run": "🏃", + ":bike": "🚲", + ":soccer": "⚽️", + ":football": "🏈", + ":8ball": "🎱", + ":clapper": "🎬", + ":microphone": "🎤", + ":cheese": "🧀", +}; + +// list of emojis in a dictionary, indexed by emoji unicode +var emojiDict = {}; +_.each(data, function (unicode, source) { + if (!emojiDict[unicode]) { + emojiDict[unicode] = { + sources: [source], + unicode: unicode, + description: source, + }; + } else { + emojiDict[unicode].sources.push(source); + } +}); + +var emojis = _.values(emojiDict); + +return emojis; + +}); diff --git a/addons/mail/static/src/js/emojis_mixin.js b/addons/mail/static/src/js/emojis_mixin.js new file mode 100644 index 00000000..aa535d1d --- /dev/null +++ b/addons/mail/static/src/js/emojis_mixin.js @@ -0,0 +1,91 @@ +odoo.define('mail.emoji_mixin', function (require) { +"use strict"; + +var emojis = require('mail.emojis'); + +/** + * This mixin gathers a few methods that are used to handle emojis. + * + * It's used to: + * + * - handle the click on an emoji from a dropdown panel and add it to the related textarea/input + * - format text and wrap the emojis around <span class="o_mail_emoji"> to make them look nicer + * + * Methods are based on the collections of emojis available in mail.emojis + * + */ +return { + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This method should be bound to a click event on an emoji. + * (used in text element's emojis dropdown list) + * + * It assumes that a ``_getTargetTextElement`` method is defined that will return the related + * textarea/input element in which the emoji will be inserted. + * + * @param {MouseEvent} ev + */ + _onEmojiClick: function (ev) { + var unicode = ev.currentTarget.textContent.trim(); + var textInput = this._getTargetTextElement($(ev.currentTarget))[0]; + var selectionStart = textInput.selectionStart; + + textInput.value = textInput.value.slice(0, selectionStart) + unicode + textInput.value.slice(selectionStart); + textInput.focus(); + textInput.setSelectionRange(selectionStart + unicode.length, selectionStart + unicode.length); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method is used to wrap emojis in a text message with <span class="o_mail_emoji"> + * As this returns html to be used in a 't-raw' argument, it first makes sure that the + * passed text message is html escaped for safety reasons. + * + * @param {String} message a text message to format + */ + _formatText: function (message) { + message = this._htmlEscape(message); + message = this._wrapEmojis(message); + message = message.replace(/(?:\r\n|\r|\n)/g, '<br>'); + + return message; + }, + + /** + * Adapted from qweb2.js#html_escape to avoid formatting '&' + * + * @param {String} s + * @private + */ + _htmlEscape: function (s) { + if (s == null) { + return ''; + } + return String(s).replace(/</g, '<').replace(/>/g, '>'); + }, + + /** + * Will use the mail.emojis library to wrap emojis unicode around a span with a special font + * that will make them look nicer (colored, ...). + * + * @param {String} message + */ + _wrapEmojis: function (message) { + emojis.forEach(function (emoji) { + message = message.replace( + new RegExp(emoji.unicode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + '<span class="o_mail_emoji">' + emoji.unicode + '</span>' + ); + }); + + return message; + } +}; + +}); diff --git a/addons/mail/static/src/js/field_char.js b/addons/mail/static/src/js/field_char.js new file mode 100644 index 00000000..1a1b90ec --- /dev/null +++ b/addons/mail/static/src/js/field_char.js @@ -0,0 +1,56 @@ +odoo.define('sms.onchange_in_keyup', function (require) { +"use strict"; + +var FieldChar = require('web.basic_fields').FieldChar; +FieldChar.include({ + + //-------------------------------------------------------------------------- + // Public + //------------------------------------------------------------------------- + + /** + * Support a key-based onchange in text field. In order to avoid too much + * rpc to the server _triggerOnchange is throttled (once every second max) + * + */ + init: function () { + this._super.apply(this, arguments); + this._triggerOnchange = _.throttle(this._triggerOnchange, 1000, {leading: false}); + }, + + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Trigger the 'change' event at key down. It allows to trigger an onchange + * while typing which may be interesting in some cases. Otherwise onchange + * is triggered only on blur. + * + * @override + * @private + */ + _onKeydown: function () { + this._super.apply(this, arguments); + if (this.nodeOptions.onchange_on_keydown) { + this._triggerOnchange(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Triggers the 'change' event to refresh the value. Throttled at init to + * avoid spaming server. + * + * @private + */ + _triggerOnchange: function () { + this.$input.trigger('change'); + }, +}); + +}); diff --git a/addons/mail/static/src/js/field_char_emojis.js b/addons/mail/static/src/js/field_char_emojis.js new file mode 100644 index 00000000..012a68c2 --- /dev/null +++ b/addons/mail/static/src/js/field_char_emojis.js @@ -0,0 +1,18 @@ +odoo.define('mail.field_char_emojis', function (require) { +"use strict"; + +var basicFields = require('web.basic_fields'); +var registry = require('web.field_registry'); +var FieldEmojiCommon = require('mail.field_emojis_common'); +var MailEmojisMixin = require('mail.emoji_mixin'); + +/** + * Extension of the FieldChar that will add emojis support + */ +var FieldCharEmojis = basicFields.FieldChar.extend(MailEmojisMixin, FieldEmojiCommon); + +registry.add('char_emojis', FieldCharEmojis); + +return FieldCharEmojis; + +}); diff --git a/addons/mail/static/src/js/field_emojis_common.js b/addons/mail/static/src/js/field_emojis_common.js new file mode 100644 index 00000000..79215ac7 --- /dev/null +++ b/addons/mail/static/src/js/field_emojis_common.js @@ -0,0 +1,136 @@ +odoo.define('mail.field_emojis_common', function (require) { +"use strict"; + +var basicFields = require('web.basic_fields'); +var core = require('web.core'); +var emojis = require('mail.emojis'); +var MailEmojisMixin = require('mail.emoji_mixin'); +var _onEmojiClickMixin = MailEmojisMixin._onEmojiClick; +var QWeb = core.qweb; + +/* + * Common code for FieldTextEmojis and FieldCharEmojis + */ +var FieldEmojiCommon = { + /** + * @override + * @private + */ + init: function () { + this._super.apply(this, arguments); + this._triggerOnchange = _.throttle(this._triggerOnchange, 1000, {leading: false}); + this.emojis = emojis; + }, + + /** + * @override + */ + on_attach_callback: function () { + this._attachEmojisDropdown(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + this._super.apply(this, arguments); + + if (this.mode !== 'edit') { + this.$el.html(this._formatText(this.$el.text())); + } + }, + + /** + * Overridden because we need to add the Emoji to the input AND trigger + * the 'change' event to refresh the value. + * + * @override + * @private + */ + _onEmojiClick: function () { + _onEmojiClickMixin.apply(this, arguments); + this._isDirty = true; + this.$input.trigger('change'); + }, + + /** + * + * By default, the 'change' event is only triggered when the text element is blurred. + * + * We override this method because we want to update the value while + * the user is typing his message (and not only on blur). + * + * @override + * @private + */ + _onKeydown: function () { + this._super.apply(this, arguments); + if (this.nodeOptions.onchange_on_keydown) { + this._triggerOnchange(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Used by MailEmojisMixin, check its document for more info. + * + * @private + */ + _getTargetTextElement() { + return this.$el; + }, + + /** + * Triggers the 'change' event to refresh the value. + * This method is throttled to run at most once every second. + * (to avoid spamming the server while the user is typing his message) + * + * @private + */ + _triggerOnchange: function () { + this.$input.trigger('change'); + }, + + /** + * This will add an emoji button that shows the emojis selection dropdown. + * + * Should be used inside 'on_attach_callback' because we need the element to be attached to the form first. + * That's because the $emojisIcon element needs to be rendered outside of this $el + * (which is an text element, that can't 'contain' any other elements). + * + * @private + */ + _attachEmojisDropdown: function () { + if (!this.$emojisIcon) { + this.$emojisIcon = $(QWeb.render('mail.EmojisDropdown', {widget: this})); + this.$emojisIcon.find('.o_mail_emoji').on('click', this._onEmojiClick.bind(this)); + + if (this.$el.filter('span.o_field_translate').length) { + // multi-languages activated, place the button on the left of the translation button + this.$emojisIcon.addClass('o_mail_emojis_dropdown_translation'); + } + if (this.$el.filter('textarea').length) { + this.$emojisIcon.addClass('o_mail_emojis_dropdown_textarea'); + } + this.$el.last().after(this.$emojisIcon); + } + + if (this.mode === 'edit') { + this.$emojisIcon.show(); + } else { + this.$emojisIcon.hide(); + } + } +}; + +return FieldEmojiCommon; + +}); diff --git a/addons/mail/static/src/js/field_text_emojis.js b/addons/mail/static/src/js/field_text_emojis.js new file mode 100644 index 00000000..8ed14ecf --- /dev/null +++ b/addons/mail/static/src/js/field_text_emojis.js @@ -0,0 +1,18 @@ +odoo.define('mail.field_text_emojis', function (require) { +"use strict"; + +var basicFields = require('web.basic_fields'); +var registry = require('web.field_registry'); +var FieldEmojiCommon = require('mail.field_emojis_common'); +var MailEmojisMixin = require('mail.emoji_mixin'); + +/** + * Extension of the FieldText that will add emojis support + */ +var FieldTextEmojis = basicFields.FieldText.extend(MailEmojisMixin, FieldEmojiCommon); + +registry.add('text_emojis', FieldTextEmojis); + +return FieldTextEmojis; + +}); diff --git a/addons/mail/static/src/js/main.js b/addons/mail/static/src/js/main.js new file mode 100644 index 00000000..d19a8edb --- /dev/null +++ b/addons/mail/static/src/js/main.js @@ -0,0 +1,126 @@ +odoo.define('mail/static/src/js/main.js', function (require) { +'use strict'; + +const ModelManager = require('mail/static/src/model/model_manager.js'); + +const env = require('web.commonEnv'); + +const { Store } = owl; +const { EventBus } = owl.core; + +async function createMessaging() { + await new Promise(resolve => { + /** + * Called when all JS resources are loaded. This is useful in order + * to do some processing after other JS files have been parsed, for + * example new models or patched models that are coming from + * other modules, because some of those patches might need to be + * applied before messaging initialization. + */ + window.addEventListener('load', resolve); + }); + /** + * All JS resources are loaded, but not necessarily processed. + * We assume no messaging-related modules return any Promise, + * therefore they should be processed *at most* asynchronously at + * "Promise time". + */ + await new Promise(resolve => setTimeout(resolve)); + /** + * Some models require session data, like locale text direction (depends on + * fully loaded translation). + */ + await env.session.is_bound; + + env.modelManager.start(); + /** + * Create the messaging singleton record. + */ + env.messaging = env.models['mail.messaging'].create(); +} + +/** + * Messaging store + */ +const store = new Store({ + env, + state: { + messagingRevNumber: 0, + }, +}); + +/** + * Registry of models. + */ +env.models = {}; +/** + * Environment keys used in messaging. + */ +Object.assign(env, { + autofetchPartnerImStatus: true, + destroyMessaging() { + if (env.modelManager) { + env.modelManager.deleteAll(); + env.messaging = undefined; + } + }, + disableAnimation: false, + isMessagingInitialized() { + if (!this.messaging) { + return false; + } + return this.messaging.isInitialized; + }, + /** + * States whether the environment is in QUnit test or not. + * + * Useful to prevent some behaviour in QUnit tests, like applying + * style of attachment that uses url. + */ + isQUnitTest: false, + loadingBaseDelayDuration: 400, + messaging: undefined, + messagingBus: new EventBus(), + /** + * Promise which becomes resolved when messaging is created. + * + * Useful for discuss widget to know when messaging is created, because this + * is an essential condition to make it work. + */ + messagingCreatedPromise: createMessaging(), + modelManager: new ModelManager(env), + store, +}); + +/** + * Components cannot use web.bus, because they cannot use + * EventDispatcherMixin, and webclient cannot easily access env. + * Communication between webclient and components by core.bus + * (usable by webclient) and messagingBus (usable by components), which + * the messaging service acts as mediator since it can easily use both + * kinds of buses. + */ +env.bus.on( + 'hide_home_menu', + null, + () => env.messagingBus.trigger('hide_home_menu') +); +env.bus.on( + 'show_home_menu', + null, + () => env.messagingBus.trigger('show_home_menu') +); +env.bus.on( + 'will_hide_home_menu', + null, + () => env.messagingBus.trigger('will_hide_home_menu') +); +env.bus.on( + 'will_show_home_menu', + null, + () => env.messagingBus.trigger('will_show_home_menu') +); + +env.messagingCreatedPromise.then(() => env.messaging.start()); + +}); diff --git a/addons/mail/static/src/js/many2many_tags_email.js b/addons/mail/static/src/js/many2many_tags_email.js new file mode 100644 index 00000000..6648ef50 --- /dev/null +++ b/addons/mail/static/src/js/many2many_tags_email.js @@ -0,0 +1,135 @@ +odoo.define('mail.many2manytags', function (require) { +"use strict"; + +var BasicModel = require('web.BasicModel'); +var core = require('web.core'); +var form_common = require('web.view_dialogs'); +var field_registry = require('web.field_registry'); +var relational_fields = require('web.relational_fields'); + +var M2MTags = relational_fields.FieldMany2ManyTags; +var _t = core._t; + +BasicModel.include({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} record - an element from the localData + * @param {string} fieldName + * @return {Promise<Object>} the promise is resolved with the + * invalidPartnerIds + */ + _setInvalidMany2ManyTagsEmail: function (record, fieldName) { + var self = this; + var localID = (record._changes && fieldName in record._changes) ? + record._changes[fieldName] : + record.data[fieldName]; + var list = this._applyX2ManyOperations(this.localData[localID]); + var invalidPartnerIds = []; + _.each(list.data, function (id) { + var record = self.localData[id]; + if (!record.data.email) { + invalidPartnerIds.push(record); + } + }); + var def; + if (invalidPartnerIds.length) { + // remove invalid partners + var changes = {operation: 'DELETE', ids: _.pluck(invalidPartnerIds, 'id')}; + def = this._applyX2ManyChange(record, fieldName, changes); + } + return Promise.resolve(def).then(function () { + return { + invalidPartnerIds: _.pluck(invalidPartnerIds, 'res_id'), + }; + }); + }, +}); + +var FieldMany2ManyTagsEmail = M2MTags.extend({ + tag_template: "FieldMany2ManyTagsEmail", + fieldsToFetch: _.extend({}, M2MTags.prototype.fieldsToFetch, { + email: {type: 'char'}, + }), + specialData: "_setInvalidMany2ManyTagsEmail", + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Open a popup for each invalid partners (without email) to fill the email. + * + * @private + * @returns {Promise} + */ + _checkEmailPopup: function () { + var self = this; + + var popupDefs = []; + var validPartners = []; + + // propose the user to correct invalid partners + _.each(this.record.specialData[this.name].invalidPartnerIds, function (resID) { + var def = new Promise(function (resolve, reject) { + var pop = new form_common.FormViewDialog(self, { + res_model: self.field.relation, + res_id: resID, + context: self.record.context, + title: "", + on_saved: function (record) { + if (record.data.email) { + validPartners.push(record.res_id); + } + }, + }).open(); + pop.on('closed', self, function () { + resolve(); + }); + }); + popupDefs.push(def); + }); + return Promise.all(popupDefs).then(function() { + // All popups have been processed for the given ids + // It is now time to set the final value with valid partners ids. + validPartners = _.uniq(validPartners); + if (validPartners.length) { + var values = _.map(validPartners, function (id) { + return {id: id}; + }); + self._setValue({ + operation: 'ADD_M2M', + ids: values, + }); + } + }); + }, + /** + * Override to check if all many2many values have an email set before + * rendering the widget. + * + * @override + * @private + */ + _render: function () { + var self = this; + var _super = this._super.bind(this); + return new Promise(function (resolve, reject) { + if (self.record.specialData[self.name].invalidPartnerIds.length) { + resolve(self._checkEmailPopup()); + } else { + resolve(); + } + }).then(function () { + return _super.apply(self, arguments); + }); + }, +}); + +field_registry.add('many2many_tags_email', FieldMany2ManyTagsEmail); + +}); diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js new file mode 100644 index 00000000..6a5b5270 --- /dev/null +++ b/addons/mail/static/src/js/many2one_avatar_user.js @@ -0,0 +1,68 @@ +odoo.define('mail.Many2OneAvatarUser', function (require) { + "use strict"; + + // This module defines an extension of the Many2OneAvatar widget, which is + // integrated with the messaging system. The Many2OneAvatarUser is designed + // to display people, and when the avatar of those people is clicked, it + // opens a DM chat window with the corresponding user. + // + // This widget is supported on many2one fields pointing to 'res.users'. + // + // Usage: + // <field name="user_id" widget="many2one_avatar_user"/> + // + // The widget is designed to be extended, to support many2one fields pointing + // to other models than 'res.users'. + + const fieldRegistry = require('web.field_registry'); + const { Many2OneAvatar } = require('web.relational_fields'); + + const { Component } = owl; + + const Many2OneAvatarUser = Many2OneAvatar.extend({ + events: Object.assign({}, Many2OneAvatar.prototype.events, { + 'click .o_m2o_avatar': '_onAvatarClicked', + }), + // This widget is only supported on many2ones pointing to 'res.users' + supportedModels: ['res.users'], + + init() { + this._super(...arguments); + if (!this.supportedModels.includes(this.field.relation)) { + throw new Error(`This widget is only supported on many2one fields pointing to ${JSON.stringify(this.supportedModels)}`); + } + if (this.mode === 'readonly') { + this.className += ' o_clickable_m2o_avatar'; + } + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * When the avatar is clicked, open a DM chat window with the + * corresponding user. + * + * @private + * @param {MouseEvent} ev + */ + async _onAvatarClicked(ev) { + ev.stopPropagation(); // in list view, prevent from opening the record + const env = Component.env; + await env.messaging.openChat({ userId: this.value.res_id }); + } + }); + + const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({ + _template: 'mail.KanbanMany2OneAvatarUser', + }); + + fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser); + fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser); + + return { + Many2OneAvatarUser, + KanbanMany2OneAvatarUser, + }; +}); diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js new file mode 100644 index 00000000..a7299599 --- /dev/null +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -0,0 +1,202 @@ +odoo.define('mail.systray.ActivityMenu', function (require) { +"use strict"; + +var core = require('web.core'); +var session = require('web.session'); +var SystrayMenu = require('web.SystrayMenu'); +var Widget = require('web.Widget'); +var Time = require('web.time'); +var QWeb = core.qweb; + +const { Component } = owl; + +/** + * Menu item appended in the systray part of the navbar, redirects to the next + * activities of all app + */ +var ActivityMenu = Widget.extend({ + name: 'activity_menu', + template:'mail.systray.ActivityMenu', + events: { + 'click .o_mail_activity_action': '_onActivityActionClick', + 'click .o_mail_preview': '_onActivityFilterClick', + 'show.bs.dropdown': '_onActivityMenuShow', + 'hide.bs.dropdown': '_onActivityMenuHide', + }, + start: function () { + this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items'); + Component.env.bus.on('activity_updated', this, this._updateCounter); + this._updateCounter(); + this._updateActivityPreview(); + return this._super(); + }, + //-------------------------------------------------- + // Private + //-------------------------------------------------- + /** + * Make RPC and get current user's activity details + * @private + */ + _getActivityData: function () { + var self = this; + + return self._rpc({ + model: 'res.users', + method: 'systray_get_activities', + args: [], + kwargs: {context: session.user_context}, + }).then(function (data) { + self._activities = data; + self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count || 0; }, 0); + self.$('.o_notification_counter').text(self.activityCounter); + self.$el.toggleClass('o_no_notification', !self.activityCounter); + }); + }, + /** + * Get particular model view to redirect on click of activity scheduled on that model. + * @private + * @param {string} model + */ + _getActivityModelViewID: function (model) { + return this._rpc({ + model: model, + method: 'get_activity_view_id' + }); + }, + /** + * Return views to display when coming from systray depending on the model. + * + * @private + * @param {string} model + * @returns {Array[]} output the list of views to display. + */ + _getViewsList(model) { + return [[false, 'kanban'], [false, 'list'], [false, 'form']]; + }, + /** + * Update(render) activity system tray view on activity updation. + * @private + */ + _updateActivityPreview: function () { + var self = this; + self._getActivityData().then(function (){ + self._$activitiesPreview.html(QWeb.render('mail.systray.ActivityMenu.Previews', { + widget: self, + Time: Time + })); + }); + }, + /** + * update counter based on activity status(created or Done) + * @private + * @param {Object} [data] key, value to decide activity created or deleted + * @param {String} [data.type] notification type + * @param {Boolean} [data.activity_deleted] when activity deleted + * @param {Boolean} [data.activity_created] when activity created + */ + _updateCounter: function (data) { + if (data) { + if (data.activity_created) { + this.activityCounter ++; + } + if (data.activity_deleted && this.activityCounter > 0) { + this.activityCounter --; + } + this.$('.o_notification_counter').text(this.activityCounter); + this.$el.toggleClass('o_no_notification', !this.activityCounter); + } + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * Redirect to specific action given its xml id or to the activity + * view of the current model if no xml id is provided + * + * @private + * @param {MouseEvent} ev + */ + _onActivityActionClick: function (ev) { + ev.stopPropagation(); + this.$('.dropdown-toggle').dropdown('toggle'); + var targetAction = $(ev.currentTarget); + var actionXmlid = targetAction.data('action_xmlid'); + if (actionXmlid) { + this.do_action(actionXmlid); + } else { + var domain = [['activity_ids.user_id', '=', session.uid]] + if (targetAction.data('domain')) { + domain = domain.concat(targetAction.data('domain')) + } + + this.do_action({ + type: 'ir.actions.act_window', + name: targetAction.data('model_name'), + views: [[false, 'activity'], [false, 'kanban'], [false, 'list'], [false, 'form']], + view_mode: 'activity', + res_model: targetAction.data('res_model'), + domain: domain, + }, { + clear_breadcrumbs: true, + }); + } + }, + + /** + * Redirect to particular model view + * @private + * @param {MouseEvent} event + */ + _onActivityFilterClick: function (event) { + // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_preview). + var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data()); + var context = {}; + if (data.filter === 'my') { + context['search_default_activities_overdue'] = 1; + context['search_default_activities_today'] = 1; + } else { + context['search_default_activities_' + data.filter] = 1; + } + // Necessary because activity_ids of mail.activity.mixin has auto_join + // So, duplicates are faking the count and "Load more" doesn't show up + context['force_search_count'] = 1; + + var domain = [['activity_ids.user_id', '=', session.uid]] + if (data.domain) { + domain = domain.concat(data.domain) + } + + this.do_action({ + type: 'ir.actions.act_window', + name: data.model_name, + res_model: data.res_model, + views: this._getViewsList(data.res_model), + search_view_id: [false], + domain: domain, + context:context, + }, { + clear_breadcrumbs: true, + }); + }, + /** + * @private + */ + _onActivityMenuShow: function () { + document.body.classList.add('modal-open'); + this._updateActivityPreview(); + }, + /** + * @private + */ + _onActivityMenuHide: function () { + document.body.classList.remove('modal-open'); + }, +}); + +SystrayMenu.Items.push(ActivityMenu); + +return ActivityMenu; + +}); diff --git a/addons/mail/static/src/js/tools/debug_manager.js b/addons/mail/static/src/js/tools/debug_manager.js new file mode 100644 index 00000000..798765eb --- /dev/null +++ b/addons/mail/static/src/js/tools/debug_manager.js @@ -0,0 +1,33 @@ +odoo.define('mail.DebugManager.Backend', function (require) { +"use strict"; + +var core = require('web.core'); +var DebugManager = require('web.DebugManager.Backend'); + +var _t = core._t; +/** + * adds a new method available for the debug manager, called by the "Manage Messages" button. + * + */ +DebugManager.include({ + getMailMessages: function () { + var selectedIDs = this._controller.getSelectedIds(); + if (!selectedIDs.length) { + console.warn(_t("No message available")); + return; + } + this.do_action({ + res_model: 'mail.message', + name: _t('Manage Messages'), + views: [[false, 'list'], [false, 'form']], + type: 'ir.actions.act_window', + domain: [['res_id', '=', selectedIDs[0]], ['model', '=', this._controller.modelName]], + context: { + default_res_model: this._controller.modelName, + default_res_id: selectedIDs[0], + }, + }); + }, +}); + +}); diff --git a/addons/mail/static/src/js/tours/mail.js b/addons/mail/static/src/js/tours/mail.js new file mode 100644 index 00000000..8870abc6 --- /dev/null +++ b/addons/mail/static/src/js/tours/mail.js @@ -0,0 +1,59 @@ +odoo.define('mail.tour', function (require) { +"use strict"; + +var core = require('web.core'); +var tour = require('web_tour.tour'); + +var _t = core._t; + +tour.register('mail_tour', { + url: "/web#action=mail.widgets.discuss", + sequence: 80, +}, [{ + trigger: '.o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd', + content: _t("<p>Channels make it easy to organize information across different topics and groups.</p> <p>Try to <b>create your first channel</b> (e.g. sales, marketing, product XYZ, after work party, etc).</p>"), + position: 'bottom', +}, { + trigger: '.o_DiscussSidebar_itemNewInput', + content: _t("<p>Create a channel here.</p>"), + position: 'bottom', + auto: true, + run: function (actions) { + var t = new Date().getTime(); + actions.text("SomeChannel_" + t, this.$anchor); + }, +}, { + trigger: ".o_DiscussSidebar_newChannelAutocompleteSuggestions", + content: _t("<p>Create a public or private channel.</p>"), + position: 'right', + run() { + this.$consumeEventAnchors.find('li:first').click(); + }, +}, { + trigger: '.o_Discuss_thread .o_ComposerTextInput_textarea', + content: _t("<p><b>Write a message</b> to the members of the channel here.</p> <p>You can notify someone with <i>'@'</i> or link another channel with <i>'#'</i>. Start your message with <i>'/'</i> to get the list of possible commands.</p>"), + position: "top", + width: 350, + run: function (actions) { + var t = new Date().getTime(); + actions.text("SomeText_" + t, this.$anchor); + }, +}, { + trigger: '.o_Discuss_thread .o_Composer_buttonSend', + content: _t("Post your message on the thread"), + position: "top", +}, { + trigger: '.o_Discuss_thread .o_Message_commandStar', + content: _t("Messages can be <b>starred</b> to remind you to check back later."), + position: "bottom", +}, { + trigger: '.o_DiscussSidebarItem.o-starred-box', + content: _t("Once a message has been starred, you can come back and review it at any time here."), + position: "bottom", +}, { + trigger: '.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeaderItemAdd', + content: _t("<p><b>Chat with coworkers</b> in real-time using direct messages.</p><p><i>You might need to invite users from the Settings app first.</i></p>"), + position: 'bottom', +}]); + +}); diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js new file mode 100644 index 00000000..d267623d --- /dev/null +++ b/addons/mail/static/src/js/utils.js @@ -0,0 +1,187 @@ +odoo.define('mail.utils', function (require) { +"use strict"; + +var core = require('web.core'); + +var _t = core._t; + +/** + * WARNING: this is not enough to unescape potential XSS contained in htmlString, transformFunction + * should handle it or it should be handled after/before calling parseAndTransform. So if the result + * of this function is used in a t-raw, be very careful. + * + * @param {string} htmlString + * @param {function} transformFunction + * @returns {string} + */ +function parseAndTransform(htmlString, transformFunction) { + var openToken = "OPEN" + Date.now(); + var string = htmlString.replace(/</g, openToken); + var children; + try { + children = $('<div>').html(string).contents(); + } catch (e) { + children = $('<div>').html('<pre>' + string + '</pre>').contents(); + } + return _parseAndTransform(children, transformFunction) + .replace(new RegExp(openToken, "g"), "<"); +} + +/** + * @param {Node[]} nodes + * @param {function} transformFunction with: + * param node + * param function + * return string + * @return {string} + */ +function _parseAndTransform(nodes, transformFunction) { + return _.map(nodes, function (node) { + return transformFunction(node, function () { + return _parseAndTransform(node.childNodes, transformFunction); + }); + }).join(""); +} + +// Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url +// Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match. +// And further extended to include Latin-1 Supplement, Latin Extended-A, Latin Extended-B and Latin Extended Additional. +var urlRegexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._+~#=\u00C0-\u024F\u1E00-\u1EFF]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_+.~#?&'$//=;\u00C0-\u024F\u1E00-\u1EFF]*)/gi; +/** + * @param {string} text + * @param {Object} [attrs={}] + * @return {string} linkified text + */ +function linkify(text, attrs) { + attrs = attrs || {}; + if (attrs.target === undefined) { + attrs.target = '_blank'; + } + if (attrs.target === '_blank') { + attrs.rel = 'noreferrer noopener'; + } + attrs = _.map(attrs, function (value, key) { + return key + '="' + _.escape(value) + '"'; + }).join(' '); + return text.replace(urlRegexp, function (url) { + var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url; + return '<a ' + attrs + ' href="' + href + '">' + url + '</a>'; + }); +} + +function addLink(node, transformChildren) { + if (node.nodeType === 3) { // text node + const linkified = linkify(node.data); + if (linkified !== node.data) { + const div = document.createElement('div'); + div.innerHTML = linkified; + for (const childNode of [...div.childNodes]) { + node.parentNode.insertBefore(childNode, node); + } + node.parentNode.removeChild(node); + return linkified; + } + return node.textContent; + } + if (node.tagName === "A") return node.outerHTML; + transformChildren(); + return node.outerHTML; +} + +/** + * @param {string} htmlString + * @return {string} + */ +function htmlToTextContentInline(htmlString) { + const fragment = document.createDocumentFragment(); + const div = document.createElement('div'); + fragment.appendChild(div); + htmlString = htmlString.replace(/<br\s*\/?>/gi,' '); + try { + div.innerHTML = htmlString; + } catch (e) { + div.innerHTML = `<pre>${htmlString}</pre>`; + } + return div + .textContent + .trim() + .replace(/[\n\r]/g, '') + .replace(/\s\s+/g, ' '); +} + +function stripHTML(node, transformChildren) { + if (node.nodeType === 3) return node.data; // text node + if (node.tagName === "BR") return "\n"; + return transformChildren(); +} + +function inline(node, transform_children) { + if (node.nodeType === 3) return node.data; + if (node.nodeType === 8) return ""; + if (node.tagName === "BR") return " "; + if (node.tagName.match(/^(A|P|DIV|PRE|BLOCKQUOTE)$/)) return transform_children(); + node.innerHTML = transform_children(); + return node.outerHTML; +} + +// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False +function parseEmail(text) { + if (text){ + var result = text.match(/(.*)<(.*@.*)>/); + if (result) { + return [_.str.trim(result[1]), _.str.trim(result[2])]; + } + result = text.match(/(.*@.*)/); + if (result) { + return [_.str.trim(result[1]), _.str.trim(result[1])]; + } + return [text, false]; + } +} + +/** + * Returns an escaped conversion of a content. + * + * @param {string} content + * @returns {string} + */ +function escapeAndCompactTextContent(content) { + //Removing unwanted extra spaces from message + let value = owl.utils.escape(content).trim(); + value = value.replace(/(\r|\n){2,}/g, '<br/><br/>'); + value = value.replace(/(\r|\n)/g, '<br/>'); + + // prevent html space collapsing + value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); + return value; +} + +// Replaces textarea text into html text (add <p>, <a>) +// TDE note : should be done server-side, in Python -> use mail.compose.message ? +function getTextToHTML(text) { + return text + .replace(/((?:https?|ftp):\/\/[\S]+)/g,'<a href="$1">$1</a> ') + .replace(/[\n\r]/g,'<br/>'); +} + +function timeFromNow(date) { + if (moment().diff(date, 'seconds') < 45) { + return _t("now"); + } + return date.fromNow(); +} + +return { + addLink: addLink, + getTextToHTML: getTextToHTML, + htmlToTextContentInline, + inline: inline, + linkify: linkify, + parseAndTransform: parseAndTransform, + parseEmail: parseEmail, + stripHTML: stripHTML, + timeFromNow: timeFromNow, + escapeAndCompactTextContent, +}; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_cell.js b/addons/mail/static/src/js/views/activity/activity_cell.js new file mode 100644 index 00000000..d1cdca94 --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_cell.js @@ -0,0 +1,42 @@ +odoo.define("mail.ActivityCell", function (require) { + "use strict"; + + require("mail.Activity"); + const field_registry = require('web.field_registry'); + + const KanbanActivity = field_registry.get('kanban_activity'); + + const ActivityCell = KanbanActivity.extend({ + /** + * @override + * @private + */ + _render() { + // replace clock by closest deadline + const $date = $('<div class="o_closest_deadline">'); + const date = new Date(this.record.data.closest_deadline); + // To remove year only if current year + if (moment().year() === moment(date).year()) { + $date.text(date.toLocaleDateString(moment().locale(), { + day: 'numeric', month: 'short' + })); + } else { + $date.text(moment(date).format('ll')); + } + this.$('a').html($date); + if (this.record.data.activity_ids.res_ids.length > 1) { + this.$('a').append($('<span>', { + class: 'badge badge-light badge-pill border-0 ' + this.record.data.activity_state, + text: this.record.data.activity_ids.res_ids.length, + })); + } + if (this.$el.hasClass('show')) { + // note: this part of the rendering might be asynchronous + this._renderDropdown(); + } + } + }); + + return ActivityCell; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_controller.js b/addons/mail/static/src/js/views/activity/activity_controller.js new file mode 100644 index 00000000..106c5ee9 --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_controller.js @@ -0,0 +1,124 @@ +odoo.define('mail.ActivityController', function (require) { +"use strict"; + +require('mail.Activity'); +var BasicController = require('web.BasicController'); +var core = require('web.core'); +var field_registry = require('web.field_registry'); +var ViewDialogs = require('web.view_dialogs'); + +var KanbanActivity = field_registry.get('kanban_activity'); +var _t = core._t; + +var ActivityController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + empty_cell_clicked: '_onEmptyCell', + send_mail_template: '_onSendMailTemplate', + schedule_activity: '_onScheduleActivity', + }), + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @param parent + * @param model + * @param renderer + * @param {Object} params + * @param {String} params.title The title used in schedule activity dialog + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.title = params.title; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Overridden to remove the pager as it makes no sense in this view. + * + * @override + */ + _getPagingInfo: function () { + return null; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onScheduleActivity: function () { + var self = this; + var state = this.model.get(this.handle); + new ViewDialogs.SelectCreateDialog(this, { + res_model: state.model, + domain: this.model.originalDomain, + title: _.str.sprintf(_t("Search: %s"), this.title), + no_create: !this.activeActions.create, + disable_multiple_selection: true, + context: state.context, + on_selected: function (record) { + var fakeRecord = state.getKanbanActivityData({}, record[0]); + var widget = new KanbanActivity(self, 'activity_ids', fakeRecord, {}); + widget.scheduleActivity(); + }, + }).open(); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onEmptyCell: function (ev) { + var state = this.model.get(this.handle); + this.do_action({ + type: 'ir.actions.act_window', + res_model: 'mail.activity', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: ev.data.resId, + default_res_model: state.model, + default_activity_type_id: ev.data.activityTypeId, + }, + res_id: false, + }, { + on_close: this.reload.bind(this), + }); + }, + /** + * @private + * @param {CustomEvent} ev + */ + _onSendMailTemplate: function (ev) { + var templateID = ev.data.templateID; + var activityTypeID = ev.data.activityTypeID; + var state = this.model.get(this.handle); + var groupedActivities = state.grouped_activities; + var resIDS = []; + Object.keys(groupedActivities).forEach(function (resID) { + var activityByType = groupedActivities[resID]; + var activity = activityByType[activityTypeID]; + if (activity) { + resIDS.push(parseInt(resID)); + } + }); + this._rpc({ + model: this.model.modelName, + method: 'activity_send_mail', + args: [resIDS, templateID], + }); + }, +}); + +return ActivityController; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_model.js b/addons/mail/static/src/js/views/activity/activity_model.js new file mode 100644 index 00000000..cfb9e36a --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_model.js @@ -0,0 +1,124 @@ +odoo.define('mail.ActivityModel', function (require) { +'use strict'; + +const BasicModel = require('web.BasicModel'); +const session = require('web.session'); + +const ActivityModel = BasicModel.extend({ + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * Add the following (activity specific) keys when performing a `get` on the + * main list datapoint: + * - activity_types + * - activity_res_ids + * - grouped_activities + * + * @override + */ + __get: function () { + var result = this._super.apply(this, arguments); + if (result && result.model === this.modelName && result.type === 'list') { + _.extend(result, this.additionalData, {getKanbanActivityData: this.getKanbanActivityData}); + } + return result; + }, + /** + * @param {Object} activityGroup + * @param {integer} resId + * @returns {Object} + */ + getKanbanActivityData(activityGroup, resId) { + return { + data: { + activity_ids: { + model: 'mail.activity', + res_ids: activityGroup.ids, + }, + activity_state: activityGroup.state, + closest_deadline: activityGroup.o_closest_deadline, + }, + fields: { + activity_ids: {}, + activity_state: { + selection: [ + ['overdue', "Overdue"], + ['today', "Today"], + ['planned', "Planned"], + ], + }, + }, + fieldsInfo: {}, + model: this.model, + type: 'record', + res_id: resId, + getContext: function () { + return {}; + }, + }; + }, + /** + * @override + * @param {Array[]} params.domain + */ + __load: function (params) { + this.originalDomain = _.extend([], params.domain); + params.domain.push(['activity_ids', '!=', false]); + this.domain = params.domain; + this.modelName = params.modelName; + params.groupedBy = []; + var def = this._super.apply(this, arguments); + return Promise.all([def, this._fetchData()]).then(function (result) { + return result[0]; + }); + }, + /** + * @override + * @param {Array[]} [params.domain] + */ + __reload: function (handle, params) { + if (params && 'domain' in params) { + this.originalDomain = _.extend([], params.domain); + params.domain.push(['activity_ids', '!=', false]); + this.domain = params.domain; + } + if (params && 'groupBy' in params) { + params.groupBy = []; + } + var def = this._super.apply(this, arguments); + return Promise.all([def, this._fetchData()]).then(function (result) { + return result[0]; + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetch activity data. + * + * @private + * @returns {Promise} + */ + _fetchData: function () { + var self = this; + return this._rpc({ + model: "mail.activity", + method: 'get_activity_data', + kwargs: { + res_model: this.modelName, + domain: this.domain, + context: session.user_context, + } + }).then(function (result) { + self.additionalData = result; + }); + }, +}); + +return ActivityModel; + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_record.js b/addons/mail/static/src/js/views/activity/activity_record.js new file mode 100644 index 00000000..98da9dca --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_record.js @@ -0,0 +1,62 @@ +odoo.define('mail.ActivityRecord', function (require) { +"use strict"; + +var KanbanRecord = require('web.KanbanRecord'); + +var ActivityRecord = KanbanRecord.extend({ + /** + * @override + */ + init: function (parent, state) { + this._super.apply(this,arguments); + + this.fieldsInfo = state.fieldsInfo.activity; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { + this.defs = []; + this._replaceElement(this.qweb.render('activity-box', this.qweb_context)); + this.$el.on('click', this._onGlobalClick.bind(this)); + this.$el.addClass('o_activity_record'); + this._processFields(); + this._setupColor(); + return Promise.all(this.defs); + }, + /** + * @override + * @private + */ + _setFieldDisplay: function ($el, fieldName) { + this._super.apply(this, arguments); + + // attribute muted + if (this.fieldsInfo[fieldName].muted) { + $el.addClass('text-muted'); + } + }, + /** + * @override + * @private + */ + _setState: function () { + this._super.apply(this, arguments); + + // activity has a different qweb context + this.qweb_context = { + activity_image: this._getImageURL.bind(this), + record: this.record, + user_context: this.getSession().user_context, + widget: this, + }; + }, +}); +return ActivityRecord; +}); diff --git a/addons/mail/static/src/js/views/activity/activity_renderer.js b/addons/mail/static/src/js/views/activity/activity_renderer.js new file mode 100644 index 00000000..e62d416e --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_renderer.js @@ -0,0 +1,210 @@ +odoo.define('mail.ActivityRenderer', function (require) { +"use strict"; + +const AbstractRendererOwl = require('web.AbstractRendererOwl'); +const ActivityCell = require('mail.ActivityCell'); +const ActivityRecord = require('mail.ActivityRecord'); +const { ComponentAdapter } = require('web.OwlCompatibility'); +const core = require('web.core'); +const KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); +const patchMixin = require('web.patchMixin'); +const QWeb = require('web.QWeb'); +const session = require('web.session'); +const utils = require('web.utils'); + +const _t = core._t; + +const { useState } = owl.hooks; + +/** + * Owl Component Adapter for ActivityRecord which is KanbanRecord (Odoo Widget) + * TODO: Remove this adapter when ActivityRecord is a Component + */ +class ActivityRecordAdapter extends ComponentAdapter { + renderWidget() { + _.invoke(_.pluck(this.widget.subWidgets, '$el'), 'detach'); + this.widget._render(); + } + + updateWidget(nextProps) { + const state = nextProps.widgetArgs[0]; + this.widget._setState(state); + } +} + +/** + * Owl Component Adapter for ActivityCell which is BasicActivity (AbstractField) + * TODO: Remove this adapter when ActivityCell is a Component + */ +class ActivityCellAdapter extends ComponentAdapter { + renderWidget() { + this.widget._render(); + } + + updateWidget(nextProps) { + const record = nextProps.widgetArgs[1]; + this.widget._reset(record); + } +} + +/** + * Owl Component Adapter for KanbanColumnProgressBar (Odoo Widget) + * TODO: Remove this adapter when KanbanColumnProgressBar is a Component + */ +class KanbanColumnProgressBarAdapter extends ComponentAdapter { + renderWidget() { + this.widget._render(); + } + + updateWidget(nextProps) { + const options = nextProps.widgetArgs[0]; + const columnState = nextProps.widgetArgs[1]; + + const columnId = options.columnID; + const nextActiveFilter = options.progressBarStates[columnId].activeFilter; + this.widget.activeFilter = nextActiveFilter ? this.widget.activeFilter : false; + this.widget.columnState = columnState; + this.widget.computeCounters(); + } + + _trigger_up(ev) { + // KanbanColumnProgressBar triggers 3 events before being mounted + // but we don't need to listen to them in our case. + if (this.el) { + super._trigger_up(ev); + } + } +} + +class ActivityRenderer extends AbstractRendererOwl { + constructor(parent, props) { + super(...arguments); + this.qweb = new QWeb(this.env.isDebug(), {_s: session.origin}); + this.qweb.add_template(utils.json_node_to_xml(props.templates)); + this.activeFilter = useState({ + state: null, + activityTypeId: null, + resIds: [] + }); + this.widgetComponents = { + ActivityRecord, + ActivityCell, + KanbanColumnProgressBar, + }; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Gets all activity resIds in the view. + * + * @returns filtered resIds first then the rest. + */ + get activityResIds() { + const copiedActivityResIds = Array.from(this.props.activity_res_ids) + return copiedActivityResIds.sort((a, b) => this.activeFilter.resIds.includes(a) ? -1 : 0); + } + + /** + * Gets all existing activity type ids. + */ + get activityTypeIds() { + const activities = Object.values(this.props.grouped_activities); + const activityIds = activities.flatMap(Object.keys); + const uniqueIds = Array.from(new Set(activityIds)); + return uniqueIds.map(Number); + } + + getProgressBarOptions(typeId) { + return { + columnID: typeId, + progressBarStates: { + [typeId]: { + activeFilter: this.activeFilter.activityTypeId === typeId, + }, + }, + }; + } + + getProgressBarColumnState(typeId) { + const counts = { planned: 0, today: 0, overdue: 0 }; + for (let activities of Object.values(this.props.grouped_activities)) { + if (typeId in activities) { + counts[activities[typeId].state] += 1; + } + } + return { + count: Object.values(counts).reduce((x, y) => x + y), + fields: { + activity_state: { + type: 'selection', + selection: [ + ['planned', _t('Planned')], + ['today', _t('Today')], + ['overdue', _t('Overdue')], + ], + }, + }, + progressBarValues: { + field: 'activity_state', + colors: { planned: 'success', today: 'warning', overdue: 'danger' }, + counts: counts, + }, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + /** + * @private + * @param {MouseEvent} ev + */ + _onEmptyCellClicked(ev) { + this.trigger('empty_cell_clicked', { + resId: parseInt(ev.currentTarget.dataset.resId, 10), + activityTypeId: parseInt(ev.currentTarget.dataset.activityTypeId, 10), + }); + } + /** + * @private + * @param {MouseEvent} ev + */ + _onSendMailTemplateClicked(ev) { + this.trigger('send_mail_template', { + activityTypeID: parseInt(ev.currentTarget.dataset.activityTypeId, 10), + templateID: parseInt(ev.currentTarget.dataset.templateId, 10), + }); + } + /** + * @private + * @param {CustomEvent} ev + */ + _onSetProgressBarState(ev) { + if (ev.detail.values.activeFilter) { + this.activeFilter.state = ev.detail.values.activeFilter; + this.activeFilter.activityTypeId = ev.detail.columnID; + this.activeFilter.resIds = Object.entries(this.props.grouped_activities) + .filter(([, resIds]) => ev.detail.columnID in resIds && + resIds[ev.detail.columnID].state === ev.detail.values.activeFilter) + .map(([key]) => parseInt(key)); + } else { + this.activeFilter.state = null; + this.activeFilter.activityTypeId = null; + this.activeFilter.resIds = []; + } + } +} + +ActivityRenderer.components = { + ActivityRecordAdapter, + ActivityCellAdapter, + KanbanColumnProgressBarAdapter, +}; +ActivityRenderer.template = 'mail.ActivityRenderer'; + +return patchMixin(ActivityRenderer); + +}); diff --git a/addons/mail/static/src/js/views/activity/activity_view.js b/addons/mail/static/src/js/views/activity/activity_view.js new file mode 100644 index 00000000..e2e3eded --- /dev/null +++ b/addons/mail/static/src/js/views/activity/activity_view.js @@ -0,0 +1,53 @@ +odoo.define('mail.ActivityView', function (require) { +"use strict"; + +const ActivityController = require('mail.ActivityController'); +const ActivityModel = require('mail.ActivityModel'); +const ActivityRenderer = require('mail.ActivityRenderer'); +const BasicView = require('web.BasicView'); +const core = require('web.core'); +const RendererWrapper = require('web.RendererWrapper'); +const view_registry = require('web.view_registry'); + +const _lt = core._lt; + +const ActivityView = BasicView.extend({ + accesskey: "a", + display_name: _lt('Activity'), + icon: 'fa-clock-o', + config: _.extend({}, BasicView.prototype.config, { + Controller: ActivityController, + Model: ActivityModel, + Renderer: ActivityRenderer, + }), + viewType: 'activity', + searchMenuTypes: ['filter', 'favorite'], + + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + + this.loadParams.type = 'list'; + // limit makes no sense in this view as we display all records having activities + this.loadParams.limit = false; + + this.rendererParams.templates = _.findWhere(this.arch.children, { 'tag': 'templates' }); + this.controllerParams.title = this.arch.attrs.string; + }, + /** + * + * @override + */ + getRenderer(parent, state) { + state = Object.assign({}, state, this.rendererParams); + return new RendererWrapper(null, this.config.Renderer, state); + }, +}); + +view_registry.add('activity', ActivityView); + +return ActivityView; + +}); diff --git a/addons/mail/static/src/model/model_core.js b/addons/mail/static/src/model/model_core.js new file mode 100644 index 00000000..0198e47e --- /dev/null +++ b/addons/mail/static/src/model/model_core.js @@ -0,0 +1,125 @@ +odoo.define('mail/static/src/model/model_core.js', function (require) { +'use strict'; + +/** + * Module that contains registry for adding new models or patching models. + * Useful for model manager in order to generate model classes. + * + * This code is not in model manager because other JS modules should populate + * a registry, and it's difficult to ensure availability of the model manager + * when these JS modules are deployed. + */ + +const registry = {}; + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * @private + * @param {string} modelName + * @returns {Object} + */ +function _getEntryFromModelName(modelName) { + if (!registry[modelName]) { + registry[modelName] = { + dependencies: [], + factory: undefined, + name: modelName, + patches: [], + }; + } + return registry[modelName]; +} + +/** + * @private + * @param {string} modelName + * @param {string} patchName + * @param {Object} patch + * @param {Object} [param3={}] + * @param {string} [param3.type='instance'] 'instance', 'class' or 'field' + */ +function _registerPatchModel(modelName, patchName, patch, { type = 'instance' } = {}) { + const entry = _getEntryFromModelName(modelName); + Object.assign(entry, { + patches: (entry.patches || []).concat([{ + name: patchName, + patch, + type, + }]), + }); +} + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +/** + * Register a patch for static methods in model. + * + * @param {string} modelName + * @param {string} patchName + * @param {Object} patch + */ +function registerClassPatchModel(modelName, patchName, patch) { + _registerPatchModel(modelName, patchName, patch, { type: 'class' }); +} + +/** + * Register a patch for fields in model. + * + * @param {string} modelName + * @param {string} patchName + * @param {Object} patch + */ +function registerFieldPatchModel(modelName, patchName, patch) { + _registerPatchModel(modelName, patchName, patch, { type: 'field' }); +} + +/** + * Register a patch for instance methods in model. + * + * @param {string} modelName + * @param {string} patchName + * @param {Object} patch + */ +function registerInstancePatchModel(modelName, patchName, patch) { + _registerPatchModel(modelName, patchName, patch, { type: 'instance' }); +} + +/** + * @param {string} name + * @param {function} factory + * @param {string[]} [dependencies=[]] + */ +function registerNewModel(name, factory, dependencies = []) { + const entry = _getEntryFromModelName(name); + let entryDependencies = [...dependencies]; + if (name !== 'mail.model') { + entryDependencies = [...new Set(entryDependencies.concat(['mail.model']))]; + } + if (entry.factory) { + throw new Error(`Model "${name}" has already been registered!`); + } + Object.assign(entry, { + dependencies: entryDependencies, + factory, + name, + }); +} + +//------------------------------------------------------------------------------ +// Export +//------------------------------------------------------------------------------ + +return { + registerClassPatchModel, + registerFieldPatchModel, + registerInstancePatchModel, + registerNewModel, + registry, +}; + +}); diff --git a/addons/mail/static/src/model/model_errors.js b/addons/mail/static/src/model/model_errors.js new file mode 100644 index 00000000..17ecc960 --- /dev/null +++ b/addons/mail/static/src/model/model_errors.js @@ -0,0 +1,22 @@ +odoo.define('mail/static/src/model/model_errors.js', function (require) { +'use strict'; + +class RecordDeletedError extends Error { + + /** + * @override + * @param {string} recordLocalId local id of record that has been deleted + * @param {...any} args + */ + constructor(recordLocalId, ...args) { + super(...args); + this.recordLocalId = recordLocalId; + this.name = 'RecordDeletedError'; + } +} + +return { + RecordDeletedError, +}; + +}); diff --git a/addons/mail/static/src/model/model_field.js b/addons/mail/static/src/model/model_field.js new file mode 100644 index 00000000..d0c461ea --- /dev/null +++ b/addons/mail/static/src/model/model_field.js @@ -0,0 +1,820 @@ +odoo.define('mail/static/src/model/model_field.js', function (require) { +'use strict'; + +const { clear, FieldCommand } = require('mail/static/src/model/model_field_command.js'); + +/** + * Class whose instances represent field on a model. + * These field definitions are generated from declared fields in static prop + * `fields` on the model. + */ +class ModelField { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + constructor({ + compute, + default: def, + dependencies = [], + dependents = [], + env, + fieldName, + fieldType, + hashes: extraHashes = [], + inverse, + isCausal = false, + related, + relationType, + to, + } = {}) { + const id = _.uniqueId('field_'); + /** + * If set, this field acts as a computed field, and this prop + * contains the name of the instance method that computes the value + * for this field. This compute method is called on creation of record + * and whenever some of its dependencies change. @see dependencies + */ + this.compute = compute; + /** + * Default value for this field. Used on creation of this field, to + * set a value by default. + */ + this.default = def; + /** + * List of field on current record that this field depends on for its + * `compute` method. Useful to determine whether this field should be + * registered for recomputation when some record fields have changed. + * This list must be declared in model definition, or compute method + * is only computed once. + */ + this.dependencies = dependencies; + /** + * List of fields that are dependent of this field. They should never + * be declared, and are automatically generated while processing + * declared fields. This is populated by compute `dependencies` and + * `related`. + */ + this.dependents = dependents; + /** + * The messaging env. + */ + this.env = env; + /** + * Name of the field in the definition of fields on model. + */ + this.fieldName = fieldName; + /** + * Type of this field. 2 types of fields are currently supported: + * + * 1. 'attribute': fields that store primitive values like integers, + * booleans, strings, objects, array, etc. + * + * 2. 'relation': fields that relate to some other records. + */ + this.fieldType = fieldType; + /** + * List of hashes registered on this field definition. Technical + * prop that is specifically used in processing of dependent + * fields, useful to clearly identify which fields of a relation are + * dependents and must be registered for computed. Indeed, not all + * related records may have a field that depends on changed field, + * especially when dependency is defined on sub-model on a relation in + * a super-model. + * + * To illustrate the purpose of this hash, suppose following definition + * of models and fields: + * + * - 3 models (A, B, C) and 3 fields (x, y, z) + * - A.fields: { x: one2one(C, inverse: x') } + * - B extends A + * - B.fields: { z: related(x.y) } + * - C.fields: { y: attribute } + * + * Visually: + * x' + * <----------- + * A -----------> C { y } + * ^ x + * | + * | (extends) + * | + * B { z = x.y } + * + * If z has a dependency on x.y, it means y has a dependent on x'.z. + * Note that field z exists on B but not on all A. To determine which + * kinds of records in relation x' are dependent on y, y is aware of an + * hash on this dependent, and any dependents who has this hash in list + * of hashes are actual dependents. + */ + this.hashes = extraHashes.concat([id]); + /** + * Identification for this field definition. Useful to map a dependent + * from a dependency. Indeed, declared field definitions use + * 'dependencies' but technical process need inverse as 'dependents'. + * Dependencies just need name of fields, but dependents cannot just + * rely on inverse field names because these dependents are a subset. + */ + this.id = id; + /** + * This prop only makes sense in a relational field. This contains + * the name of the field name in the inverse relation. This may not + * be defined in declared field definitions, but processed relational + * field definitions always have inverses. + */ + this.inverse = inverse; + /** + * This prop only makes sense in a relational field. If set, when this + * relation is removed, the related record is automatically deleted. + */ + this.isCausal = isCausal; + /** + * If set, this field acts as a related field, and this prop contains + * a string that references the related field. It should have the + * following format: '<relationName>.<relatedFieldName>', where + * <relationName> is a relational field name on this model or a parent + * model (note: could itself be computed or related), and + * <relatedFieldName> is the name of field on the records that are + * related to current record from this relation. When there are more + * than one record in the relation, it maps all related fields per + * record in relation. + * + * FIXME: currently flatten map due to bug, improvement is planned + * see Task-id 2261221 + */ + this.related = related; + /** + * This prop only makes sense in a relational field. Determine which + * type of relation there is between current record and other records. + * 4 types of relation are supported: 'one2one', 'one2many', 'many2one' + * and 'many2many'. + */ + this.relationType = relationType; + /** + * This prop only makes sense in a relational field. Determine which + * model name this relation refers to. + */ + this.to = to; + + if (!this.default && this.fieldType === 'relation') { + // default value for relational fields is the empty command + this.default = []; + } + } + + /** + * Define an attribute field. + * + * @param {Object} [options] + * @returns {Object} + */ + static attr(options) { + return Object.assign({ fieldType: 'attribute' }, options); + } + + /** + * Define a many2many field. + * + * @param {string} modelName + * @param {Object} [options] + * @returns {Object} + */ + static many2many(modelName, options) { + return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2many' })); + } + + /** + * Define a many2one field. + * + * @param {string} modelName + * @param {Object} [options] + * @returns {Object} + */ + static many2one(modelName, options) { + return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2one' })); + } + + /** + * Define a one2many field. + * + * @param {string} modelName + * @param {Object} [options] + * @returns {Object} + */ + static one2many(modelName, options) { + return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2many' })); + } + + /** + * Define a one2one field. + * + * @param {string} modelName + * @param {Object} [options] + * @returns {Object} + */ + static one2one(modelName, options) { + return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2one' })); + } + + /** + * Clears the value of this field on the given record. It consists of + * setting this to its default value. In particular, using `clear` is the + * only way to write `undefined` on a field, as long as `undefined` is its + * default value. Relational fields are always unlinked before the default + * is applied. + * + * @param {mail.model} record + * @param {options} [options] + * @returns {boolean} whether the value changed for the current field + */ + clear(record, options) { + let hasChanged = false; + if (this.fieldType === 'relation') { + if (this.parseAndExecuteCommands(record, [['unlink-all']], options)) { + hasChanged = true; + } + } + if (this.parseAndExecuteCommands(record, this.default, options)) { + hasChanged = true; + } + return hasChanged; + } + + /** + * Combine current field definition with provided field definition and + * return the combined field definition. Useful to track list of hashes of + * a given field, which is necessary for the working of dependent fields + * (computed and related fields). + * + * @param {ModelField} field + * @returns {ModelField} + */ + combine(field) { + return new ModelField(Object.assign({}, this, { + dependencies: this.dependencies.concat(field.dependencies), + hashes: this.hashes.concat(field.hashes), + })); + } + + /** + * Compute method when this field is related. + * + * @private + * @param {mail.model} record + */ + computeRelated(record) { + const [relationName, relatedFieldName] = this.related.split('.'); + const Model = record.constructor; + const relationField = Model.__fieldMap[relationName]; + if (['one2many', 'many2many'].includes(relationField.relationType)) { + const newVal = []; + for (const otherRecord of record[relationName]) { + const OtherModel = otherRecord.constructor; + const otherField = OtherModel.__fieldMap[relatedFieldName]; + const otherValue = otherField.get(otherRecord); + if (otherValue) { + if (otherValue instanceof Array) { + // avoid nested array if otherField is x2many too + // TODO IMP task-2261221 + for (const v of otherValue) { + newVal.push(v); + } + } else { + newVal.push(otherValue); + } + } + } + if (this.fieldType === 'relation') { + return [['replace', newVal]]; + } + return newVal; + } + const otherRecord = record[relationName]; + if (otherRecord) { + const OtherModel = otherRecord.constructor; + const otherField = OtherModel.__fieldMap[relatedFieldName]; + const newVal = otherField.get(otherRecord); + if (newVal === undefined) { + return clear(); + } + if (this.fieldType === 'relation') { + return [['replace', newVal]]; + } + return newVal; + } + return clear(); + } + + /** + * Get the value associated to this field. Relations must convert record + * local ids to records. + * + * @param {mail.model} record + * @returns {any} + */ + get(record) { + if (this.fieldType === 'attribute') { + return this.read(record); + } + if (this.fieldType === 'relation') { + if (['one2one', 'many2one'].includes(this.relationType)) { + return this.read(record); + } + return [...this.read(record)]; + } + throw new Error(`cannot get field with unsupported type ${this.fieldType}.`); + } + + /** + * Parses newVal for command(s) and executes them. + * + * @param {mail.model} record + * @param {any} newVal + * @param {Object} [options] + * @returns {boolean} whether the value changed for the current field + */ + parseAndExecuteCommands(record, newVal, options) { + if (newVal instanceof FieldCommand) { + // single command given + return newVal.execute(this, record, options); + } + if (newVal instanceof Array && newVal[0] instanceof FieldCommand) { + // multi command given + let hasChanged = false; + for (const command of newVal) { + if (command.execute(this, record, options)) { + hasChanged = true; + } + } + return hasChanged; + } + // not a command + return this.set(record, newVal, options); + } + + /** + * Get the raw value associated to this field. For relations, this means + * the local id or list of local ids of records in this relational field. + * + * @param {mail.model} record + * @returns {any} + */ + read(record) { + return record.__values[this.fieldName]; + } + + /** + * Set a value on this field. The format of the value comes from business + * code. + * + * @param {mail.model} record + * @param {any} newVal + * @param {Object} [options] + * @param {boolean} [options.hasToUpdateInverse] whether updating the + * current field should also update its inverse field. Only applies to + * relational fields. Typically set to false only during the process of + * updating the inverse field itself, to avoid unnecessary recursion. + * @returns {boolean} whether the value changed for the current field + */ + set(record, newVal, options) { + const currentValue = this.read(record); + if (this.fieldType === 'attribute') { + if (currentValue === newVal) { + return false; + } + record.__values[this.fieldName] = newVal; + return true; + } + if (this.fieldType === 'relation') { + let hasChanged = false; + for (const val of newVal) { + switch (val[0]) { + case 'create': + if (this._setRelationCreate(record, val[1], options)) { + hasChanged = true; + } + break; + case 'insert': + if (this._setRelationInsert(record, val[1], options)) { + hasChanged = true; + } + break; + case 'insert-and-replace': + if (this._setRelationInsertAndReplace(record, val[1], options)) { + hasChanged = true; + } + break; + case 'link': + if (this._setRelationLink(record, val[1], options)) { + hasChanged = true; + } + break; + case 'replace': + if (this._setRelationReplace(record, val[1], options)) { + hasChanged = true; + } + break; + case 'unlink': + if (this._setRelationUnlink(record, val[1], options)) { + hasChanged = true; + } + break; + case 'unlink-all': + if (this._setRelationUnlink(record, currentValue, options)) { + hasChanged = true; + } + break; + } + } + return hasChanged; + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} modelName + * @param {Object} [options] + */ + static _relation(modelName, options) { + return Object.assign({ + fieldType: 'relation', + to: modelName, + }, options); + } + + /** + * Converts given value to expected format for x2many processing, which is + * an iterable of records. + * + * @private + * @param {mail.model|mail.model[]} newValue + * @param {Object} [param1={}] + * @param {boolean} [param1.hasToVerify=true] whether the value has to be + * verified @see `_verifyRelationalValue` + * @returns {mail.model[]} + */ + _convertX2ManyValue(newValue, { hasToVerify = true } = {}) { + if (typeof newValue[Symbol.iterator] === 'function') { + if (hasToVerify) { + for (const value of newValue) { + this._verifyRelationalValue(value); + } + } + return newValue; + } + if (hasToVerify) { + this._verifyRelationalValue(newValue); + } + return [newValue]; + } + + /** + * Set on this relational field in 'create' mode. Basically data provided + * during set on this relational field contain data to create new records, + * which themselves must be linked to record of this field by means of + * this field. + * + * @private + * @param {mail.model} record + * @param {Object|Object[]} data + * @param {Object} [options] + * @returns {boolean} whether the value changed for the current field + */ + _setRelationCreate(record, data, options) { + const OtherModel = this.env.models[this.to]; + const other = this.env.modelManager._create(OtherModel, data); + return this._setRelationLink(record, other, options); + } + + /** + * Set on this relational field in 'insert' mode. Basically data provided + * during set on this relational field contain data to insert records, + * which themselves must be linked to record of this field by means of + * this field. + * + * @private + * @param {mail.model} record + * @param {Object|Object[]} data + * @param {Object} [options] + * @returns {boolean} whether the value changed for the current field + */ + _setRelationInsert(record, data, options) { + const OtherModel = this.env.models[this.to]; + const other = this.env.modelManager._insert(OtherModel, data); + return this._setRelationLink(record, other, options); + } + + /** + * Set on this relational field in 'insert-and-repalce' mode. Basically + * data provided during set on this relational field contain data to insert + * records, which themselves must replace value on this field. + * + * @private + * @param {mail.model} record + * @param {Object|Object[]} data + * @param {Object} [options] + * @returns {boolean} whether the value changed for the current field + */ + _setRelationInsertAndReplace(record, data, options) { + const OtherModel = this.env.models[this.to]; + const newValue = this.env.modelManager._insert(OtherModel, data); + return this._setRelationReplace(record, newValue, options); + } + + /** + * Set a 'link' operation on this relational field. + * + * @private + * @param {mail.model|mail.model[]} newValue + * @param {Object} [options] + * @returns {boolean} whether the value changed for the current field + */ + _setRelationLink(record, newValue, options) { + switch (this.relationType) { + case 'many2many': + case 'one2many': + return this._setRelationLinkX2Many(record, newValue, options); + case 'many2one': + case 'one2one': + return this._setRelationLinkX2One(record, newValue, options); + } + } + + /** + * Handling of a `set` 'link' of a x2many relational field. + * + * @private + * @param {mail.model} record + * @param {mail.model|mail.model[]} newValue + * @param {Object} [param2={}] + * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the + * current field should also update its inverse field. Typically set to + * false only during the process of updating the inverse field itself, to + * avoid unnecessary recursion. + * @returns {boolean} whether the value changed for the current field + */ + _setRelationLinkX2Many(record, newValue, { hasToUpdateInverse = true } = {}) { + const recordsToLink = this._convertX2ManyValue(newValue); + const otherRecords = this.read(record); + + let hasChanged = false; + for (const recordToLink of recordsToLink) { + // other record already linked, avoid linking twice + if (otherRecords.has(recordToLink)) { + continue; + } + hasChanged = true; + // link other records to current record + otherRecords.add(recordToLink); + // link current record to other records + if (hasToUpdateInverse) { + this.env.modelManager._update( + recordToLink, + { [this.inverse]: [['link', record]] }, + { hasToUpdateInverse: false } + ); + } + } + return hasChanged; + } + + /** + * Handling of a `set` 'link' of an x2one relational field. + * + * @private + * @param {mail.model} record + * @param {mail.model} recordToLink + * @param {Object} [param2={}] + * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the + * current field should also update its inverse field. Typically set to + * false only during the process of updating the inverse field itself, to + * avoid unnecessary recursion. + * @returns {boolean} whether the value changed for the current field + */ + _setRelationLinkX2One(record, recordToLink, { hasToUpdateInverse = true } = {}) { + this._verifyRelationalValue(recordToLink); + const prevOtherRecord = this.read(record); + // other record already linked, avoid linking twice + if (prevOtherRecord === recordToLink) { + return false; + } + // unlink to properly update previous inverse before linking new value + this._setRelationUnlinkX2One(record, { hasToUpdateInverse }); + // link other record to current record + record.__values[this.fieldName] = recordToLink; + // link current record to other record + if (hasToUpdateInverse) { + this.env.modelManager._update( + recordToLink, + { [this.inverse]: [['link', record]] }, + { hasToUpdateInverse: false } + ); + } + return true; + } + + /** + * Set a 'replace' operation on this relational field. + * + * @private + * @param {mail.model} record + * @param {mail.model|mail.model[]} newValue + * @param {Object} [options] + * @returns {boolean} whether the value changed for the current field + */ + _setRelationReplace(record, newValue, options) { + if (['one2one', 'many2one'].includes(this.relationType)) { + // for x2one replace is just link + return this._setRelationLinkX2One(record, newValue, options); + } + + // for x2many: smart process to avoid unnecessary unlink/link + let hasChanged = false; + let hasToReorder = false; + const otherRecordsSet = this.read(record); + const otherRecordsList = [...otherRecordsSet]; + const recordsToReplaceList = [...this._convertX2ManyValue(newValue)]; + const recordsToReplaceSet = new Set(recordsToReplaceList); + + // records to link + const recordsToLink = []; + for (let i = 0; i < recordsToReplaceList.length; i++) { + const recordToReplace = recordsToReplaceList[i]; + if (!otherRecordsSet.has(recordToReplace)) { + recordsToLink.push(recordToReplace); + } + if (otherRecordsList[i] !== recordToReplace) { + hasToReorder = true; + } + } + if (this._setRelationLinkX2Many(record, recordsToLink, options)) { + hasChanged = true; + } + + // records to unlink + const recordsToUnlink = []; + for (let i = 0; i < otherRecordsList.length; i++) { + const otherRecord = otherRecordsList[i]; + if (!recordsToReplaceSet.has(otherRecord)) { + recordsToUnlink.push(otherRecord); + } + if (recordsToReplaceList[i] !== otherRecord) { + hasToReorder = true; + } + } + if (this._setRelationUnlinkX2Many(record, recordsToUnlink, options)) { + hasChanged = true; + } + + // reorder result + if (hasToReorder) { + otherRecordsSet.clear(); + for (const record of recordsToReplaceList) { + otherRecordsSet.add(record); + } + hasChanged = true; + } + return hasChanged; + } + + /** + * Set an 'unlink' operation on this relational field. + * + * @private + * @param {mail.model} record + * @param {mail.model|mail.model[]} newValue + * @param {Object} [options] + * @returns {boolean} whether the value changed for the current field + */ + _setRelationUnlink(record, newValue, options) { + switch (this.relationType) { + case 'many2many': + case 'one2many': + return this._setRelationUnlinkX2Many(record, newValue, options); + case 'many2one': + case 'one2one': + return this._setRelationUnlinkX2One(record, options); + } + } + + /** + * Handling of a `set` 'unlink' of a x2many relational field. + * + * @private + * @param {mail.model} record + * @param {mail.model|mail.model[]} newValue + * @param {Object} [param2={}] + * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the + * current field should also update its inverse field. Typically set to + * false only during the process of updating the inverse field itself, to + * avoid unnecessary recursion. + * @returns {boolean} whether the value changed for the current field + */ + _setRelationUnlinkX2Many(record, newValue, { hasToUpdateInverse = true } = {}) { + const recordsToUnlink = this._convertX2ManyValue( + newValue, + { hasToVerify: false } + ); + const otherRecords = this.read(record); + + let hasChanged = false; + for (const recordToUnlink of recordsToUnlink) { + // unlink other record from current record + const wasLinked = otherRecords.delete(recordToUnlink); + if (!wasLinked) { + continue; + } + hasChanged = true; + // unlink current record from other records + if (hasToUpdateInverse) { + if (!recordToUnlink.exists()) { + // This case should never happen ideally, but the current + // way of handling related relational fields make it so that + // deleted records are not always reflected immediately in + // these related fields. + continue; + } + // apply causality + if (this.isCausal) { + this.env.modelManager._delete(recordToUnlink); + } else { + this.env.modelManager._update( + recordToUnlink, + { [this.inverse]: [['unlink', record]] }, + { hasToUpdateInverse: false } + ); + } + } + } + return hasChanged; + } + + /** + * Handling of a `set` 'unlink' of a x2one relational field. + * + * @private + * @param {mail.model} record + * @param {Object} [param1={}] + * @param {boolean} [param1.hasToUpdateInverse=true] whether updating the + * current field should also update its inverse field. Typically set to + * false only during the process of updating the inverse field itself, to + * avoid unnecessary recursion. + * @returns {boolean} whether the value changed for the current field + */ + _setRelationUnlinkX2One(record, { hasToUpdateInverse = true } = {}) { + const otherRecord = this.read(record); + // other record already unlinked, avoid useless processing + if (!otherRecord) { + return false; + } + // unlink other record from current record + record.__values[this.fieldName] = undefined; + // unlink current record from other record + if (hasToUpdateInverse) { + if (!otherRecord.exists()) { + // This case should never happen ideally, but the current + // way of handling related relational fields make it so that + // deleted records are not always reflected immediately in + // these related fields. + return; + } + // apply causality + if (this.isCausal) { + this.env.modelManager._delete(otherRecord); + } else { + this.env.modelManager._update( + otherRecord, + { [this.inverse]: [['unlink', record]] }, + { hasToUpdateInverse: false } + ); + } + } + return true; + } + + /** + * Verifies the given relational value makes sense for the current field. + * In particular the given value must be a record, it must be non-deleted, + * and it must originates from relational `to` model (or its subclasses). + * + * @private + * @param {mail.model} record + * @throws {Error} if record does not satisfy related model + */ + _verifyRelationalValue(record) { + const OtherModel = this.env.models[this.to]; + if (!OtherModel.get(record.localId, { isCheckingInheritance: true })) { + throw Error(`Record ${record.localId} is not valid for relational field ${this.fieldName}.`); + } + } + +} + +return ModelField; + +}); diff --git a/addons/mail/static/src/model/model_field_command.js b/addons/mail/static/src/model/model_field_command.js new file mode 100644 index 00000000..f4e59a95 --- /dev/null +++ b/addons/mail/static/src/model/model_field_command.js @@ -0,0 +1,73 @@ +odoo.define('mail/static/src/model/model_field_command.js', function (require) { +'use strict'; + +/** + * Allows field update to detect if the value it received is a command to + * execute (in which was it will be an instance of this class) or an actual + * value to set (in all other cases). + */ +class FieldCommand { + /** + * @constructor + * @param {function} func function to call when executing this command. + * The function should ALWAYS return a boolean value + * to indicate whether the value changed. + */ + constructor(func) { + this.func = func; + } + + /** + * @param {ModelField} field + * @param {mail.model} record + * @param {options} [options] + * @returns {boolean} whether the value changed for the current field + */ + execute(field, record, options) { + return this.func(field, record, options); + } +} + +/** + * Returns a clear command to give to the model manager at create/update. + */ +function clear() { + return new FieldCommand((field, record, options) => + field.clear(record, options) + ); +} + +/** + * Returns a decrement command to give to the model manager at create/update. + * + * @param {number} [amount=1] + */ +function decrement(amount = 1) { + return new FieldCommand((field, record, options) => { + const oldValue = field.get(record); + return field.set(record, oldValue - amount, options); + }); +} + +/** + * Returns a increment command to give to the model manager at create/update. + * + * @param {number} [amount=1] + */ +function increment(amount = 1) { + return new FieldCommand((field, record, options) => { + const oldValue = field.get(record); + return field.set(record, oldValue + amount, options); + }); +} + +return { + // class + FieldCommand, + // shortcuts + clear, + decrement, + increment, +}; + +}); diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js new file mode 100644 index 00000000..6ffcd8d1 --- /dev/null +++ b/addons/mail/static/src/model/model_manager.js @@ -0,0 +1,1098 @@ +odoo.define('mail/static/src/model/model_manager.js', function (require) { +'use strict'; + +const { registry } = require('mail/static/src/model/model_core.js'); +const ModelField = require('mail/static/src/model/model_field.js'); +const { patchClassMethods, patchInstanceMethods } = require('mail/static/src/utils/utils.js'); + +/** + * Inner separator used between bits of information in string that is used to + * identify a dependent of a field. Useful to determine which record and field + * to register for compute during this update cycle. + */ +const DEPENDENT_INNER_SEPARATOR = "--//--//--"; + +/** + * Object that manage models and records, notably their update cycle: whenever + * some records are requested for update (either with model static method + * `create()` or record method `update()`), this object processes them with + * direct field & and computed field updates. + */ +class ModelManager { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + constructor(env) { + /** + * Inner separator used inside string to represent dependents. + * Set as public attribute so that it can be used by model field. + */ + this.DEPENDENT_INNER_SEPARATOR = DEPENDENT_INNER_SEPARATOR; + /** + * The messaging env. + */ + this.env = env; + + //---------------------------------------------------------------------- + // Various variables that are necessary to handle an update cycle. The + // goal of having an update cycle is to delay the execution of computes, + // life-cycle hooks and potential UI re-renders until the last possible + // moment, for performance reasons. + //---------------------------------------------------------------------- + + /** + * Set of records that have been created during the current update + * cycle. Useful to trigger `_created()` hook methods. + */ + this._createdRecords = new Set(); + /** + * Tracks whether something has changed during the current update cycle. + * Useful to notify components (through the store) that some records + * have been changed. + */ + this._hasAnyChangeDuringCycle = false; + /** + * Set of records that have been updated during the current update + * cycle. Useful to allow observers (typically components) to detect + * whether specific records have been changed. + */ + this._updatedRecords = new Set(); + /** + * Fields flagged to call compute during an update cycle. + * For instance, when a field with dependents got update, dependent + * fields should update themselves by invoking compute at end of + * update cycle. Key is of format + * <record-local-id><DEPENDENT_INNER_SEPARATOR><fieldName>, and + * determine record and field to be computed. Keys are strings because + * it must contain only one occurrence of pair record/field, and we want + * O(1) reads/writes. + */ + this._toComputeFields = new Map(); + /** + * Map of "update after" on records that have been registered. + * These are processed after any explicit update and computed/related + * fields. + */ + this._toUpdateAfters = new Map(); + } + + /** + * Called when all JS modules that register or patch models have been + * done. This launches generation of models. + */ + start() { + /** + * Generate the models. + */ + Object.assign(this.env.models, this._generateModels()); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns all records of provided model that match provided criteria. + * + * @param {mail.model} Model class + * @param {function} [filterFunc] + * @returns {mail.model[]} records matching criteria. + */ + all(Model, filterFunc) { + const allRecords = Object.values(Model.__records); + if (filterFunc) { + return allRecords.filter(filterFunc); + } + return allRecords; + } + + /** + * Register a record that has been created, and manage update of records + * from this record creation. + * + * @param {mail.model} Model class + * @param {Object|Object[]} [data={}] + * If data is an iterable, multiple records will be created. + * @returns {mail.model|mail.model[]} newly created record(s) + */ + create(Model, data = {}) { + const res = this._create(Model, data); + this._flushUpdateCycle(); + return res; + } + + /** + * Delete the record. After this operation, it's as if this record never + * existed. Note that relation are removed, which may delete more relations + * if some of them are causal. + * + * @param {mail.model} record + */ + delete(record) { + this._delete(record); + this._flushUpdateCycle(); + } + + /** + * Delete all records. + */ + deleteAll() { + for (const Model of Object.values(this.env.models)) { + for (const record of Object.values(Model.__records)) { + this._delete(record); + } + } + this._flushUpdateCycle(); + } + + /** + * Returns whether the given record still exists. + * + * @param {mail.model} Model class + * @param {mail.model} record + * @returns {boolean} + */ + exists(Model, record) { + return Model.__records[record.localId] ? true : false; + } + + /** + * Get the record of provided model that has provided + * criteria, if it exists. + * + * @param {mail.model} Model class + * @param {function} findFunc + * @returns {mail.model|undefined} the record of model matching criteria, if + * exists. + */ + find(Model, findFunc) { + return this.all(Model).find(findFunc); + } + + /** + * Gets the unique record of provided model that matches the given + * identifying data, if it exists. + * @see `_createRecordLocalId` for criteria of identification. + * + * @param {mail.model} Model class + * @param {Object} data + * @returns {mail.model|undefined} + */ + findFromIdentifyingData(Model, data) { + const localId = Model._createRecordLocalId(data); + return Model.get(localId); + } + + /** + * This method returns the record of provided model that matches provided + * local id. Useful to convert a local id to a record. + * Note that even if there's a record in the system having provided local + * id, if the resulting record is not an instance of this model, this getter + * assumes the record does not exist. + * + * @param {mail.model} Model class + * @param {string} localId + * @param {Object} param2 + * @param {boolean} [param2.isCheckingInheritance=false] + * @returns {mail.model|undefined} record, if exists + */ + get(Model, localId, { isCheckingInheritance = false } = {}) { + if (!localId) { + return; + } + const record = Model.__records[localId]; + if (record) { + return record; + } + if (!isCheckingInheritance) { + return; + } + // support for inherited models (eg. relation targeting `mail.model`) + for (const SubModel of Object.values(this.env.models)) { + if (!(SubModel.prototype instanceof Model)) { + continue; + } + const record = SubModel.__records[localId]; + if (record) { + return record; + } + } + return; + } + + /** + * This method creates a record or updates one of provided Model, based on + * provided data. This method assumes that records are uniquely identifiable + * per "unique find" criteria from data on Model. + * + * @param {mail.model} Model class + * @param {Object|Object[]} data + * If data is an iterable, multiple records will be created/updated. + * @returns {mail.model|mail.model[]} created or updated record(s). + */ + insert(Model, data) { + const res = this._insert(Model, data); + this._flushUpdateCycle(); + return res; + } + + /** + * Process an update on provided record with provided data. Updating + * a record consists of applying direct updates first (i.e. explicit + * ones from `data`) and then indirect ones (i.e. compute/related fields + * and "after updates"). + * + * @param {mail.model} record + * @param {Object} data + * @returns {boolean} whether any value changed for the current record + */ + update(record, data) { + const res = this._update(record, data); + this._flushUpdateCycle(); + return res; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {mail.model} Model class + * @param {Object} patch + */ + _applyModelPatchFields(Model, patch) { + for (const [fieldName, field] of Object.entries(patch)) { + if (!Model.fields[fieldName]) { + Model.fields[fieldName] = field; + } else { + Object.assign(Model.fields[fieldName].dependencies, field.dependencies); + } + } + } + + /** + * @private + * @param {Object} Models + * @throws {Error} in case some declared fields are not correct. + */ + _checkDeclaredFieldsOnModels(Models) { + for (const Model of Object.values(Models)) { + for (const fieldName in Model.fields) { + const field = Model.fields[fieldName]; + // 0. Get parented declared fields + const parentedMatchingFields = []; + let TargetModel = Model.__proto__; + while (Models[TargetModel.modelName]) { + if (TargetModel.fields) { + const matchingField = TargetModel.fields[fieldName]; + if (matchingField) { + parentedMatchingFields.push(matchingField); + } + } + TargetModel = TargetModel.__proto__; + } + // 1. Field type is required. + if (!(['attribute', 'relation'].includes(field.fieldType))) { + throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`); + } + // 2. Invalid keys based on field type. + if (field.fieldType === 'attribute') { + const invalidKeys = Object.keys(field).filter(key => + ![ + 'compute', + 'default', + 'dependencies', + 'fieldType', + 'related', + ].includes(key) + ); + if (invalidKeys.length > 0) { + throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`); + } + } + if (field.fieldType === 'relation') { + const invalidKeys = Object.keys(field).filter(key => + ![ + 'compute', + 'default', + 'dependencies', + 'fieldType', + 'inverse', + 'isCausal', + 'related', + 'relationType', + 'to', + ].includes(key) + ); + if (invalidKeys.length > 0) { + throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`); + } + if (!Models[field.to]) { + throw new Error(`Relational field "${Model.modelName}/${fieldName}" targets to unknown model name "${field.to}".`); + } + if (field.isCausal && !(['one2many', 'one2one'].includes(field.relationType))) { + throw new Error(`Relational field "${Model.modelName}/${fieldName}" has "isCausal" true with a relation of type "${field.relationType}" but "isCausal" is only supported for "one2many" and "one2one".`); + } + } + // 3. Computed field. + if (field.compute && !(typeof field.compute === 'string')) { + throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" must be a string (instance method name).`); + } + if (field.compute && !(Model.prototype[field.compute])) { + throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" does not refer to an instance method of this Model.`); + } + if ( + field.dependencies && + (!field.compute && !parentedMatchingFields.some(field => field.compute)) + ) { + throw new Error(`Field "${Model.modelName}/${fieldName} contains dependendencies but no compute method in itself or parented matching fields (dependencies only make sense for compute fields)."`); + } + if ( + (field.compute || parentedMatchingFields.some(field => field.compute)) && + (field.dependencies || parentedMatchingFields.some(field => field.dependencies)) + ) { + if (!(field.dependencies instanceof Array)) { + throw new Error(`Compute field "${Model.modelName}/${fieldName}" dependencies must be an array of field names.`); + } + const unknownDependencies = field.dependencies.every(dependency => !(Model.fields[dependency])); + if (unknownDependencies.length > 0) { + throw new Error(`Compute field "${Model.modelName}/${fieldName}" contains some unknown dependencies: "${unknownDependencies.join(", ")}".`); + } + } + // 4. Related field. + if (field.compute && field.related) { + throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`); + } + if (field.related) { + if (!(typeof field.related === 'string')) { + throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`); + } + const [relationName, relatedFieldName, other] = field.related.split('.'); + if (!relationName || !relatedFieldName || other) { + throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`); + } + // find relation on self or parents. + let relatedRelation; + let TargetModel = Model; + while (Models[TargetModel.modelName] && !relatedRelation) { + if (TargetModel.fields) { + relatedRelation = TargetModel.fields[relationName]; + } + TargetModel = TargetModel.__proto__; + } + if (!relatedRelation) { + throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown relation name "${relationName}".`); + } + if (relatedRelation.fieldType !== 'relation') { + throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to non-relational field "${relationName}".`); + } + // Assuming related relation is valid... + // find field name on related model or any parents. + const RelatedModel = Models[relatedRelation.to]; + let relatedField; + TargetModel = RelatedModel; + while (Models[TargetModel.modelName] && !relatedField) { + if (TargetModel.fields) { + relatedField = TargetModel.fields[relatedFieldName]; + } + TargetModel = TargetModel.__proto__; + } + if (!relatedField) { + throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown related model field "${relatedFieldName}".`); + } + if (relatedField.fieldType !== field.fieldType) { + throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch type with its related model field.`); + } + if ( + relatedField.fieldType === 'relation' && + relatedField.to !== field.to + ) { + throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch target model name with its related model field.`); + } + } + } + } + } + + /** + * @private + * @param {Object} Models + * @throws {Error} in case some fields are not correct. + */ + _checkProcessedFieldsOnModels(Models) { + for (const Model of Object.values(Models)) { + for (const fieldName in Model.fields) { + const field = Model.fields[fieldName]; + if (!(['attribute', 'relation'].includes(field.fieldType))) { + throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`); + } + if (field.compute && field.related) { + throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`); + } + if (field.fieldType === 'attribute') { + continue; + } + if (!field.relationType) { + throw new Error( + `Field "${Model.modelName}/${fieldName}" must define a relation type in "relationType".` + ); + } + if (!(['one2one', 'one2many', 'many2one', 'many2many'].includes(field.relationType))) { + throw new Error( + `Field "${Model.modelName}/${fieldName}" has invalid relation type "${field.relationType}".` + ); + } + if (!field.inverse) { + throw new Error( + `Field "${ + Model.modelName + }/${ + fieldName + }" must define an inverse relation name in "inverse".` + ); + } + if (!field.to) { + throw new Error( + `Relation "${ + Model.modelNames + }/${ + fieldName + }" must define a model name in "to" (1st positional parameter of relation field helpers).` + ); + } + const RelatedModel = Models[field.to]; + if (!RelatedModel) { + throw new Error( + `Model name of relation "${Model.modelName}/${fieldName}" does not exist.` + ); + } + const inverseField = RelatedModel.fields[field.inverse]; + if (!inverseField) { + throw new Error( + `Relation "${ + Model.modelName + }/${ + fieldName + }" has no inverse field "${RelatedModel.modelName}/${field.inverse}".` + ); + } + if (inverseField.inverse !== fieldName) { + throw new Error( + `Inverse field name of relation "${ + Model.modelName + }/${ + fieldName + }" does not match with field name of relation "${ + RelatedModel.modelName + }/${ + inverseField.inverse + }".` + ); + } + const allSelfAndParentNames = []; + let TargetModel = Model; + while (TargetModel) { + allSelfAndParentNames.push(TargetModel.modelName); + TargetModel = TargetModel.__proto__; + } + if (!allSelfAndParentNames.includes(inverseField.to)) { + throw new Error( + `Relation "${ + Model.modelName + }/${ + fieldName + }" has inverse relation "${ + RelatedModel.modelName + }/${ + field.inverse + }" misconfigured (currently "${ + inverseField.to + }", should instead refer to this model or parented models: ${ + allSelfAndParentNames.map(name => `"${name}"`).join(', ') + }?)` + ); + } + if ( + (field.relationType === 'many2many' && inverseField.relationType !== 'many2many') || + (field.relationType === 'one2one' && inverseField.relationType !== 'one2one') || + (field.relationType === 'one2many' && inverseField.relationType !== 'many2one') || + (field.relationType === 'many2one' && inverseField.relationType !== 'one2many') + ) { + throw new Error( + `Mismatch relations types "${ + Model.modelName + }/${ + fieldName + }" (${ + field.relationType + }) and "${ + RelatedModel.modelName + }/${ + field.inverse + }" (${ + inverseField.relationType + }).` + ); + } + } + } + } + + /** + * @private + * @param {mail.model} Model class + * @param {Object|Object[]} [data={}] + * @returns {mail.model|mail.model[]} + */ + _create(Model, data = {}) { + const isMulti = typeof data[Symbol.iterator] === 'function'; + const dataList = isMulti ? data : [data]; + const records = []; + for (const data of dataList) { + /** + * 1. Ensure the record can be created: localId must be unique. + */ + const localId = Model._createRecordLocalId(data); + if (Model.get(localId)) { + throw Error(`A record already exists for model "${Model.modelName}" with localId "${localId}".`); + } + /** + * 2. Prepare record state. Assign various keys and values that are + * expected to be found on every record. + */ + const record = new Model({ valid: true }); + Object.assign(record, { + // The messaging env. + env: this.env, + // The unique record identifier. + localId, + // Field values of record. + __values: {}, + // revNumber of record for detecting changes in useStore. + __state: 0, + }); + // Ensure X2many relations are Set initially (other fields can stay undefined). + for (const field of Model.__fieldList) { + if (field.fieldType === 'relation') { + if (['one2many', 'many2many'].includes(field.relationType)) { + record.__values[field.fieldName] = new Set(); + } + } + } + /** + * 3. Register record and invoke the life-cycle hook `_willCreate.` + * After this step the record is in a functioning state and it is + * considered existing. + */ + Model.__records[record.localId] = record; + record._willCreate(); + /** + * 4. Write provided data, default data, and register computes. + */ + const data2 = {}; + for (const field of Model.__fieldList) { + // `undefined` should have the same effect as not passing the field + if (data[field.fieldName] !== undefined) { + data2[field.fieldName] = data[field.fieldName]; + } else { + data2[field.fieldName] = field.default; + } + if (field.compute || field.related) { + // new record should always invoke computed fields. + this._registerToComputeField(record, field); + } + } + this._update(record, data2); + /** + * 5. Register post processing operation that are to be delayed at + * the end of the update cycle. + */ + this._createdRecords.add(record); + this._hasAnyChangeDuringCycle = true; + + records.push(record); + } + return isMulti ? records : records[0]; + } + + /** + * @private + * @param {mail.model} record + */ + _delete(record) { + const Model = record.constructor; + if (!record.exists()) { + throw Error(`Cannot delete already deleted record ${record.localId}.`); + } + record._willDelete(); + for (const field of Model.__fieldList) { + if (field.fieldType === 'relation') { + // ensure inverses are properly unlinked + field.parseAndExecuteCommands(record, [['unlink-all']]); + } + } + this._hasAnyChangeDuringCycle = true; + // TODO ideally deleting the record should be done at the top of the + // method, and it shouldn't be needed to manually remove + // _toComputeFields and _toUpdateAfters, but it is not possible until + // related are also properly unlinked during `set` + this._createdRecords.delete(record); + this._toComputeFields.delete(record); + this._toUpdateAfters.delete(record); + delete Model.__records[record.localId]; + } + + /** + * Terminates an update cycle by executing its pending operations: execute + * computed fields, execute life-cycle hooks, update rev numbers. + * + * @private + */ + _flushUpdateCycle(func) { + // Execution of computes + while (this._toComputeFields.size > 0) { + for (const [record, fields] of this._toComputeFields) { + // delete at every step to avoid recursion, indeed doCompute + // might trigger an update cycle itself + this._toComputeFields.delete(record); + if (!record.exists()) { + throw Error(`Cannot execute computes for already deleted record ${record.localId}.`); + } + while (fields.size > 0) { + for (const field of fields) { + // delete at every step to avoid recursion + fields.delete(field); + if (field.compute) { + this._update(record, { [field.fieldName]: record[field.compute]() }); + continue; + } + if (field.related) { + this._update(record, { [field.fieldName]: field.computeRelated(record) }); + continue; + } + throw new Error("No compute method defined on this field definition"); + } + } + } + } + + // Execution of _updateAfter + while (this._toUpdateAfters.size > 0) { + for (const [record, previous] of this._toUpdateAfters) { + // delete at every step to avoid recursion, indeed _updateAfter + // might trigger an update cycle itself + this._toUpdateAfters.delete(record); + if (!record.exists()) { + throw Error(`Cannot _updateAfter for already deleted record ${record.localId}.`); + } + record._updateAfter(previous); + } + } + + // Execution of _created + while (this._createdRecords.size > 0) { + for (const record of this._createdRecords) { + // delete at every step to avoid recursion, indeed _created + // might trigger an update cycle itself + this._createdRecords.delete(record); + if (!record.exists()) { + throw Error(`Cannot call _created for already deleted record ${record.localId}.`); + } + record._created(); + } + } + + // Increment record rev number (for useStore comparison) + for (const record of this._updatedRecords) { + record.__state++; + } + this._updatedRecords.clear(); + + // Trigger at most one useStore call per update cycle + if (this._hasAnyChangeDuringCycle) { + this.env.store.state.messagingRevNumber++; + this._hasAnyChangeDuringCycle = false; + } + } + + /** + * @private + * @returns {Object} + * @throws {Error} in case it cannot generate models. + */ + _generateModels() { + const allNames = Object.keys(registry); + const Models = {}; + const generatedNames = []; + let toGenerateNames = [...allNames]; + while (toGenerateNames.length > 0) { + const generatable = toGenerateNames.map(name => registry[name]).find(entry => { + let isGenerateable = true; + for (const dependencyName of entry.dependencies) { + if (!generatedNames.includes(dependencyName)) { + isGenerateable = false; + } + } + return isGenerateable; + }); + if (!generatable) { + throw new Error(`Cannot generate following Model: ${toGenerateNames.join(', ')}`); + } + // Make environment accessible from Model. + const Model = generatable.factory(Models); + Model.env = this.env; + /** + * Contains all records. key is local id, while value is the record. + */ + Model.__records = {}; + for (const patch of generatable.patches) { + switch (patch.type) { + case 'class': + patchClassMethods(Model, patch.name, patch.patch); + break; + case 'instance': + patchInstanceMethods(Model, patch.name, patch.patch); + break; + case 'field': + this._applyModelPatchFields(Model, patch.patch); + break; + } + } + if (!Object.prototype.hasOwnProperty.call(Model, 'modelName')) { + throw new Error(`Missing static property "modelName" on Model class "${Model.name}".`); + } + if (generatedNames.includes(Model.modelName)) { + throw new Error(`Duplicate model name "${Model.modelName}" shared on 2 distinct Model classes.`); + } + Models[Model.modelName] = Model; + generatedNames.push(Model.modelName); + toGenerateNames = toGenerateNames.filter(name => name !== Model.modelName); + } + /** + * Check that declared model fields are correct. + */ + this._checkDeclaredFieldsOnModels(Models); + /** + * Process declared model fields definitions, so that these field + * definitions are much easier to use in the system. For instance, all + * relational field definitions have an inverse, or fields track all their + * dependents. + */ + this._processDeclaredFieldsOnModels(Models); + /** + * Check that all model fields are correct, notably one relation + * should have matching reversed relation. + */ + this._checkProcessedFieldsOnModels(Models); + return Models; + } + + /** + * @private + * @param {mail.model} + * @param {Object|Object[]} data + * @returns {mail.model|mail.model[]} + */ + _insert(Model, data) { + const isMulti = typeof data[Symbol.iterator] === 'function'; + const dataList = isMulti ? data : [data]; + const records = []; + for (const data of dataList) { + let record = Model.findFromIdentifyingData(data); + if (!record) { + record = this._create(Model, data); + } else { + this._update(record, data); + } + records.push(record); + } + return isMulti ? records : records[0]; + } + + /** + * @private + * @param {mail.model} Model class + * @param {ModelField} field + * @returns {ModelField} + */ + _makeInverseRelationField(Model, field) { + const relFunc = + field.relationType === 'many2many' ? ModelField.many2many + : field.relationType === 'many2one' ? ModelField.one2many + : field.relationType === 'one2many' ? ModelField.many2one + : field.relationType === 'one2one' ? ModelField.one2one + : undefined; + if (!relFunc) { + throw new Error(`Cannot compute inverse Relation of "${Model.modelName}/${field.fieldName}".`); + } + const inverseField = new ModelField(Object.assign( + {}, + relFunc(Model.modelName, { inverse: field.fieldName }), + { + env: this.env, + fieldName: `_inverse_${Model.modelName}/${field.fieldName}`, + modelManager: this, + } + )); + return inverseField; + } + + /** + * This function processes definition of declared fields in provided models. + * Basically, models have fields declared in static prop `fields`, and this + * function processes and modifies them in place so that they are fully + * configured. For instance, model relations need bi-directional mapping, but + * inverse relation may be omitted in declared field: this function auto-fill + * this inverse relation. + * + * @private + * @param {Object} Models + */ + _processDeclaredFieldsOnModels(Models) { + /** + * 1. Prepare fields. + */ + for (const Model of Object.values(Models)) { + if (!Object.prototype.hasOwnProperty.call(Model, 'fields')) { + Model.fields = {}; + } + Model.inverseRelations = []; + // Make fields aware of their field name. + for (const [fieldName, fieldData] of Object.entries(Model.fields)) { + Model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, { + env: this.env, + fieldName, + modelManager: this, + })); + } + } + /** + * 2. Auto-generate definitions of undeclared inverse relations. + */ + for (const Model of Object.values(Models)) { + for (const field of Object.values(Model.fields)) { + if (field.fieldType !== 'relation') { + continue; + } + if (field.inverse) { + continue; + } + const RelatedModel = Models[field.to]; + const inverseField = this._makeInverseRelationField(Model, field); + field.inverse = inverseField.fieldName; + RelatedModel.fields[inverseField.fieldName] = inverseField; + } + } + /** + * 3. Generate dependents and inverse-relates on fields. + * Field definitions are not yet combined, so registration of `dependents` + * may have to walk structural hierarchy of models in order to find + * the appropriate field. Also, while dependencies are defined just with + * field names, dependents require an additional data called a "hash" + * (= field id), which is a way to identify dependents in an inverse + * relation. This is necessary because dependents are a subset of an inverse + * relation. + */ + for (const Model of Object.values(Models)) { + for (const field of Object.values(Model.fields)) { + for (const dependencyFieldName of field.dependencies) { + let TargetModel = Model; + let dependencyField = TargetModel.fields[dependencyFieldName]; + while (!dependencyField) { + TargetModel = TargetModel.__proto__; + dependencyField = TargetModel.fields[dependencyFieldName]; + } + const dependent = [field.id, field.fieldName].join(DEPENDENT_INNER_SEPARATOR); + dependencyField.dependents = [ + ...new Set(dependencyField.dependents.concat([dependent])) + ]; + } + if (field.related) { + const [relationName, relatedFieldName] = field.related.split('.'); + let TargetModel = Model; + let relationField = TargetModel.fields[relationName]; + while (!relationField) { + TargetModel = TargetModel.__proto__; + relationField = TargetModel.fields[relationName]; + } + const relationFieldDependent = [ + field.id, + field.fieldName, + ].join(DEPENDENT_INNER_SEPARATOR); + relationField.dependents = [ + ...new Set(relationField.dependents.concat([relationFieldDependent])) + ]; + const OtherModel = Models[relationField.to]; + let OtherTargetModel = OtherModel; + let relatedField = OtherTargetModel.fields[relatedFieldName]; + while (!relatedField) { + OtherTargetModel = OtherTargetModel.__proto__; + relatedField = OtherTargetModel.fields[relatedFieldName]; + } + const relatedFieldDependent = [ + field.id, + relationField.inverse, + field.fieldName, + ].join(DEPENDENT_INNER_SEPARATOR); + relatedField.dependents = [ + ...new Set( + relatedField.dependents.concat([relatedFieldDependent]) + ) + ]; + } + } + } + /** + * 4. Extend definition of fields of a model with the definition of + * fields of its parents. Field definitions on self has precedence over + * parented fields. + */ + for (const Model of Object.values(Models)) { + Model.__combinedFields = {}; + for (const field of Object.values(Model.fields)) { + Model.__combinedFields[field.fieldName] = field; + } + let TargetModel = Model.__proto__; + while (TargetModel && TargetModel.fields) { + for (const targetField of Object.values(TargetModel.fields)) { + const field = Model.__combinedFields[targetField.fieldName]; + if (field) { + Model.__combinedFields[targetField.fieldName] = field.combine(targetField); + } else { + Model.__combinedFields[targetField.fieldName] = targetField; + } + } + TargetModel = TargetModel.__proto__; + } + } + /** + * 5. Register final fields and make field accessors, to redirects field + * access to field getter and to prevent field from being written + * without calling update (which is necessary to process update cycle). + */ + for (const Model of Object.values(Models)) { + // Object with fieldName/field as key/value pair, for quick access. + Model.__fieldMap = Model.__combinedFields; + // List of all fields, for iterating. + Model.__fieldList = Object.values(Model.__fieldMap); + // Add field accessors. + for (const field of Model.__fieldList) { + Object.defineProperty(Model.prototype, field.fieldName, { + get() { + return field.get(this); // this is bound to record + }, + }); + } + delete Model.__combinedFields; + } + } + + /** + * Registers compute of dependents for the given field, if applicable. + * + * @private + * @param {mail.model} record + * @param {ModelField} field + */ + _registerComputeOfDependents(record, field) { + const Model = record.constructor; + for (const dependent of field.dependents) { + const [hash, fieldName1, fieldName2] = dependent.split( + this.DEPENDENT_INNER_SEPARATOR + ); + const field1 = Model.__fieldMap[fieldName1]; + if (fieldName2) { + // "fieldName1.fieldName2" -> dependent is on another record + if (['one2many', 'many2many'].includes(field1.relationType)) { + for (const otherRecord of record[fieldName1]) { + const OtherModel = otherRecord.constructor; + const field2 = OtherModel.__fieldMap[fieldName2]; + if (field2 && field2.hashes.includes(hash)) { + this._registerToComputeField(otherRecord, field2); + } + } + } else { + const otherRecord = record[fieldName1]; + if (!otherRecord) { + continue; + } + const OtherModel = otherRecord.constructor; + const field2 = OtherModel.__fieldMap[fieldName2]; + if (field2 && field2.hashes.includes(hash)) { + this._registerToComputeField(otherRecord, field2); + } + } + } else { + // "fieldName1" only -> dependent is on current record + if (field1 && field1.hashes.includes(hash)) { + this._registerToComputeField(record, field1); + } + } + } + } + + /** + * Register a pair record/field for the compute step of the update cycle in + * progress. + * + * @private + * @param {mail.model} record + * @param {ModelField} field + */ + _registerToComputeField(record, field) { + if (!this._toComputeFields.has(record)) { + this._toComputeFields.set(record, new Set()); + } + this._toComputeFields.get(record).add(field); + } + + /** + * @private + * @param {mail.model} record + * @param {Object} data + * @param {Object} [options] + * @returns {boolean} whether any value changed for the current record + */ + _update(record, data, options) { + if (!record.exists()) { + throw Error(`Cannot update already deleted record ${record.localId}.`); + } + if (!this._toUpdateAfters.has(record)) { + // queue updateAfter before calling field.set to ensure previous + // contains the value at the start of update cycle + this._toUpdateAfters.set(record, record._updateBefore()); + } + const Model = record.constructor; + let hasChanged = false; + for (const fieldName of Object.keys(data)) { + if (data[fieldName] === undefined) { + // `undefined` should have the same effect as not passing the field + continue; + } + const field = Model.__fieldMap[fieldName]; + if (!field) { + throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${fieldName}")`); + } + const newVal = data[fieldName]; + if (!field.parseAndExecuteCommands(record, newVal, options)) { + continue; + } + hasChanged = true; + // flag all dependent fields for compute + this._registerComputeOfDependents(record, field); + } + if (hasChanged) { + this._updatedRecords.add(record); + this._hasAnyChangeDuringCycle = true; + } + return hasChanged; + } + +} + +return ModelManager; + +}); diff --git a/addons/mail/static/src/models/activity/activity.js b/addons/mail/static/src/models/activity/activity.js new file mode 100644 index 00000000..f2023aac --- /dev/null +++ b/addons/mail/static/src/models/activity/activity.js @@ -0,0 +1,355 @@ +odoo.define('mail/static/src/models/activity/activity/js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); + +function factory(dependencies) { + + class Activity extends dependencies['mail.model'] { + + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Delete the record from database and locally. + */ + async deleteServerRecord() { + await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'unlink', + args: [[this.id]], + })); + this.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('activity_category' in data) { + data2.category = data.activity_category; + } + if ('can_write' in data) { + data2.canWrite = data.can_write; + } + if ('create_date' in data) { + data2.dateCreate = data.create_date; + } + if ('date_deadline' in data) { + data2.dateDeadline = data.date_deadline; + } + if ('force_next' in data) { + data2.force_next = data.force_next; + } + if ('icon' in data) { + data2.icon = data.icon; + } + if ('id' in data) { + data2.id = data.id; + } + if ('note' in data) { + data2.note = data.note; + } + if ('state' in data) { + data2.state = data.state; + } + if ('summary' in data) { + data2.summary = data.summary; + } + + // relation + if ('activity_type_id' in data) { + if (!data.activity_type_id) { + data2.type = [['unlink-all']]; + } else { + data2.type = [ + ['insert', { + displayName: data.activity_type_id[1], + id: data.activity_type_id[0], + }], + ]; + } + } + if ('create_uid' in data) { + if (!data.create_uid) { + data2.creator = [['unlink-all']]; + } else { + data2.creator = [ + ['insert', { + id: data.create_uid[0], + display_name: data.create_uid[1], + }], + ]; + } + } + if ('mail_template_ids' in data) { + data2.mailTemplates = [['insert', data.mail_template_ids]]; + } + if ('res_id' in data && 'res_model' in data) { + data2.thread = [['insert', { + id: data.res_id, + model: data.res_model, + }]]; + } + if ('user_id' in data) { + if (!data.user_id) { + data2.assignee = [['unlink-all']]; + } else { + data2.assignee = [ + ['insert', { + id: data.user_id[0], + display_name: data.user_id[1], + }], + ]; + } + } + if ('request_partner_id' in data) { + if (!data.request_partner_id) { + data2.requestingPartner = [['unlink']]; + } else { + data2.requestingPartner = [ + ['insert', { + id: data.request_partner_id[0], + display_name: data.request_partner_id[1], + }], + ]; + } + } + + return data2; + } + + /** + * Opens (legacy) form view dialog to edit current activity and updates + * the activity when dialog is closed. + */ + edit() { + const action = { + type: 'ir.actions.act_window', + name: this.env._t("Schedule Activity"), + res_model: 'mail.activity', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.thread.id, + default_res_model: this.thread.model, + }, + res_id: this.id, + }; + this.env.bus.trigger('do-action', { + action, + options: { on_close: () => this.fetchAndUpdate() }, + }); + } + + async fetchAndUpdate() { + const [data] = await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'activity_format', + args: [this.id], + }, { shadow: true })); + let shouldDelete = false; + if (data) { + this.update(this.constructor.convertData(data)); + } else { + shouldDelete = true; + } + this.thread.refreshActivities(); + this.thread.refresh(); + if (shouldDelete) { + this.delete(); + } + } + + /** + * @param {Object} param0 + * @param {mail.attachment[]} [param0.attachments=[]] + * @param {string|boolean} [param0.feedback=false] + */ + async markAsDone({ attachments = [], feedback = false }) { + const attachmentIds = attachments.map(attachment => attachment.id); + await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'action_feedback', + args: [[this.id]], + kwargs: { + attachment_ids: attachmentIds, + feedback, + }, + })); + this.thread.refresh(); + this.delete(); + } + + /** + * @param {Object} param0 + * @param {string} param0.feedback + * @returns {Object} + */ + async markAsDoneAndScheduleNext({ feedback }) { + const action = await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'action_feedback_schedule_next', + args: [[this.id]], + kwargs: { feedback }, + })); + this.thread.refresh(); + const thread = this.thread; + this.delete(); + if (!action) { + thread.refreshActivities(); + return; + } + this.env.bus.trigger('do-action', { + action, + options: { + on_close: () => { + thread.refreshActivities(); + }, + }, + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerAssignee() { + if (!this.assigneePartner || !this.messagingCurrentPartner) { + return false; + } + return this.assigneePartner === this.messagingCurrentPartner; + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * Wysiwyg editor put `<p><br></p>` even without a note on the activity. + * This compute replaces this almost empty value by an actual empty + * value, to reduce the size the empty note takes on the UI. + * + * @private + * @returns {string|undefined} + */ + _computeNote() { + if (this.note === '<p><br></p>') { + return clear(); + } + return this.note; + } + } + + Activity.fields = { + assignee: many2one('mail.user'), + assigneePartner: many2one('mail.partner', { + related: 'assignee.partner', + }), + attachments: many2many('mail.attachment', { + inverse: 'activities', + }), + canWrite: attr({ + default: false, + }), + category: attr(), + creator: many2one('mail.user'), + dateCreate: attr(), + dateDeadline: attr(), + /** + * Backup of the feedback content of an activity to be marked as done in the popover. + * Feature-specific to restoring the feedback content when component is re-mounted. + * In all other cases, this field value should not be trusted. + */ + feedbackBackup: attr(), + force_next: attr({ + default: false, + }), + icon: attr(), + id: attr(), + isCurrentPartnerAssignee: attr({ + compute: '_computeIsCurrentPartnerAssignee', + default: false, + dependencies: [ + 'assigneePartner', + 'messagingCurrentPartner', + ], + }), + mailTemplates: many2many('mail.mail_template', { + inverse: 'activities', + }), + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + messagingCurrentPartner: many2one('mail.partner', { + related: 'messaging.currentPartner', + }), + /** + * This value is meant to be returned by the server + * (and has been sanitized before stored into db). + * Do not use this value in a 't-raw' if the activity has been created + * directly from user input and not from server data as it's not escaped. + */ + note: attr({ + compute: '_computeNote', + dependencies: [ + 'note', + ], + }), + /** + * Determines that an activity is linked to a requesting partner or not. + * It will be used notably in website slides to know who triggered the + * "request access" activity. + * Also, be useful when the assigned user is different from the + * "source" or "requesting" partner. + */ + requestingPartner: many2one('mail.partner'), + state: attr(), + summary: attr(), + /** + * Determines to which "thread" (using `mail.activity.mixin` on the + * server) `this` belongs to. + */ + thread: many2one('mail.thread', { + inverse: 'activities', + }), + type: many2one('mail.activity_type', { + inverse: 'activities', + }), + }; + + Activity.modelName = 'mail.activity'; + + return Activity; +} + +registerNewModel('mail.activity', factory); + +}); diff --git a/addons/mail/static/src/models/activity_type/activity_type.js b/addons/mail/static/src/models/activity_type/activity_type.js new file mode 100644 index 00000000..f8a621a8 --- /dev/null +++ b/addons/mail/static/src/models/activity_type/activity_type.js @@ -0,0 +1,39 @@ +odoo.define('mail/static/src/models/activity_type/activity_type.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ActivityType extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + } + + ActivityType.fields = { + activities: one2many('mail.activity', { + inverse: 'type', + }), + displayName: attr(), + id: attr(), + }; + + ActivityType.modelName = 'mail.activity_type'; + + return ActivityType; +} + +registerNewModel('mail.activity_type', factory); + +}); diff --git a/addons/mail/static/src/models/attachment/attachment.js b/addons/mail/static/src/models/attachment/attachment.js new file mode 100644 index 00000000..a49b0a87 --- /dev/null +++ b/addons/mail/static/src/models/attachment/attachment.js @@ -0,0 +1,439 @@ +odoo.define('mail/static/src/models/attachment/attachment.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); + +function factory(dependencies) { + + let nextTemporaryId = -1; + function getAttachmentNextTemporaryId() { + const id = nextTemporaryId; + nextTemporaryId -= 1; + return id; + } + class Attachment extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('filename' in data) { + data2.filename = data.filename; + } + if ('id' in data) { + data2.id = data.id; + } + if ('mimetype' in data) { + data2.mimetype = data.mimetype; + } + if ('name' in data) { + data2.name = data.name; + } + + // relation + if ('res_id' in data && 'res_model' in data) { + data2.originThread = [['insert', { + id: data.res_id, + model: data.res_model, + }]]; + } + + return data2; + } + + /** + * @override + */ + static create(data) { + const isMulti = typeof data[Symbol.iterator] === 'function'; + const dataList = isMulti ? data : [data]; + for (const data of dataList) { + if (!data.id) { + data.id = getAttachmentNextTemporaryId(); + } + } + return super.create(...arguments); + } + + /** + * View provided attachment(s), with given attachment initially. Prompts + * the attachment viewer. + * + * @static + * @param {Object} param0 + * @param {mail.attachment} [param0.attachment] + * @param {mail.attachments[]} param0.attachments + * @returns {string|undefined} unique id of open dialog, if open + */ + static view({ attachment, attachments }) { + const hasOtherAttachments = attachments && attachments.length > 0; + if (!attachment && !hasOtherAttachments) { + return; + } + if (!attachment && hasOtherAttachments) { + attachment = attachments[0]; + } else if (attachment && !hasOtherAttachments) { + attachments = [attachment]; + } + if (!attachments.includes(attachment)) { + return; + } + this.env.messaging.dialogManager.open('mail.attachment_viewer', { + attachment: [['link', attachment]], + attachments: [['replace', attachments]], + }); + } + + /** + * Remove this attachment globally. + */ + async remove() { + if (this.isUnlinkPending) { + return; + } + if (!this.isTemporary) { + this.update({ isUnlinkPending: true }); + try { + await this.async(() => this.env.services.rpc({ + model: 'ir.attachment', + method: 'unlink', + args: [this.id], + }, { shadow: true })); + } finally { + this.update({ isUnlinkPending: false }); + } + } else if (this.uploadingAbortController) { + this.uploadingAbortController.abort(); + } + this.delete(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @private + * @returns {mail.composer[]} + */ + _computeComposers() { + if (this.isTemporary) { + return []; + } + const relatedTemporaryAttachment = this.env.models['mail.attachment'] + .find(attachment => + attachment.filename === this.filename && + attachment.isTemporary + ); + if (relatedTemporaryAttachment) { + const composers = relatedTemporaryAttachment.composers; + relatedTemporaryAttachment.delete(); + return [['replace', composers]]; + } + return []; + } + + /** + * @private + * @returns {string|undefined} + */ + _computeDefaultSource() { + if (this.fileType === 'image') { + return `/web/image/${this.id}?unique=1&signature=${this.checksum}&model=ir.attachment`; + } + if (this.fileType === 'application/pdf') { + return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}?model%3Dir.attachment`; + } + if (this.fileType && this.fileType.includes('text')) { + return `/web/content/${this.id}?model%3Dir.attachment`; + } + if (this.fileType === 'youtu') { + const urlArr = this.url.split('/'); + let token = urlArr[urlArr.length - 1]; + if (token.includes('watch')) { + token = token.split('v=')[1]; + const amp = token.indexOf('&'); + if (amp !== -1) { + token = token.substring(0, amp); + } + } + return `https://www.youtube.com/embed/${token}`; + } + if (this.fileType === 'video') { + return `/web/content/${this.id}?model=ir.attachment`; + } + return clear(); + } + + /** + * @private + * @returns {string|undefined} + */ + _computeDisplayName() { + const displayName = this.name || this.filename; + if (displayName) { + return displayName; + } + return clear(); + } + + /** + * @private + * @returns {string|undefined} + */ + _computeExtension() { + const extension = this.filename && this.filename.split('.').pop(); + if (extension) { + return extension; + } + return clear(); + } + + /** + * @private + * @returns {string|undefined} + */ + _computeFileType() { + if (this.type === 'url' && !this.url) { + return clear(); + } else if (!this.mimetype) { + return clear(); + } + switch (this.mimetype) { + case 'application/pdf': + return 'application/pdf'; + case 'image/bmp': + case 'image/gif': + case 'image/jpeg': + case 'image/png': + case 'image/svg+xml': + case 'image/tiff': + case 'image/x-icon': + return 'image'; + case 'application/javascript': + case 'application/json': + case 'text/css': + case 'text/html': + case 'text/plain': + return 'text'; + case 'audio/mpeg': + case 'video/x-matroska': + case 'video/mp4': + case 'video/webm': + return 'video'; + } + if (!this.url) { + return clear(); + } + if (this.url.match('(.png|.jpg|.gif)')) { + return 'image'; + } + if (this.url.includes('youtu')) { + return 'youtu'; + } + return clear(); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsLinkedToComposer() { + return this.composers.length > 0; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsTextFile() { + if (!this.fileType) { + return false; + } + return this.fileType === 'text'; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsViewable() { + switch (this.mimetype) { + case 'application/javascript': + case 'application/json': + case 'application/pdf': + case 'audio/mpeg': + case 'image/bmp': + case 'image/gif': + case 'image/jpeg': + case 'image/png': + case 'image/svg+xml': + case 'image/tiff': + case 'image/x-icon': + case 'text/css': + case 'text/html': + case 'text/plain': + case 'video/x-matroska': + case 'video/mp4': + case 'video/webm': + return true; + default: + return false; + } + } + + /** + * @deprecated + * @private + * @returns {string} + */ + _computeMediaType() { + return this.mimetype && this.mimetype.split('/').shift(); + } + + /** + * @private + * @returns {AbortController|undefined} + */ + _computeUploadingAbortController() { + if (this.isTemporary) { + if (!this.uploadingAbortController) { + const abortController = new AbortController(); + abortController.signal.onabort = () => { + this.env.messagingBus.trigger('o-attachment-upload-abort', { + attachment: this + }); + }; + return abortController; + } + return this.uploadingAbortController; + } + return undefined; + } + } + + Attachment.fields = { + activities: many2many('mail.activity', { + inverse: 'attachments', + }), + attachmentViewer: many2many('mail.attachment_viewer', { + inverse: 'attachments', + }), + checkSum: attr(), + composers: many2many('mail.composer', { + compute: '_computeComposers', + inverse: 'attachments', + }), + defaultSource: attr({ + compute: '_computeDefaultSource', + dependencies: [ + 'checkSum', + 'fileType', + 'id', + 'url', + ], + }), + displayName: attr({ + compute: '_computeDisplayName', + dependencies: [ + 'filename', + 'name', + ], + }), + extension: attr({ + compute: '_computeExtension', + dependencies: ['filename'], + }), + filename: attr(), + fileType: attr({ + compute: '_computeFileType', + dependencies: [ + 'mimetype', + 'type', + 'url', + ], + }), + id: attr(), + isLinkedToComposer: attr({ + compute: '_computeIsLinkedToComposer', + dependencies: ['composers'], + }), + isTemporary: attr({ + default: false, + }), + isTextFile: attr({ + compute: '_computeIsTextFile', + dependencies: ['fileType'], + }), + /** + * True if an unlink RPC is pending, used to prevent multiple unlink attempts. + */ + isUnlinkPending: attr({ + default: false, + }), + isViewable: attr({ + compute: '_computeIsViewable', + dependencies: [ + 'mimetype', + ], + }), + /** + * @deprecated + */ + mediaType: attr({ + compute: '_computeMediaType', + dependencies: ['mimetype'], + }), + messages: many2many('mail.message', { + inverse: 'attachments', + }), + mimetype: attr({ + default: '', + }), + name: attr(), + originThread: many2one('mail.thread', { + inverse: 'originThreadAttachments', + }), + size: attr(), + threads: many2many('mail.thread', { + inverse: 'attachments', + }), + type: attr(), + /** + * Abort Controller linked to the uploading process of this attachment. + * Useful in order to cancel the in-progress uploading of this attachment. + */ + uploadingAbortController: attr({ + compute: '_computeUploadingAbortController', + dependencies: [ + 'isTemporary', + 'uploadingAbortController', + ], + }), + url: attr(), + }; + + Attachment.modelName = 'mail.attachment'; + + return Attachment; +} + +registerNewModel('mail.attachment', factory); + +}); diff --git a/addons/mail/static/src/models/attachment/attachment_tests.js b/addons/mail/static/src/models/attachment/attachment_tests.js new file mode 100644 index 00000000..5c09dcae --- /dev/null +++ b/addons/mail/static/src/models/attachment/attachment_tests.js @@ -0,0 +1,144 @@ +odoo.define('mail/static/src/models/attachment/attachment_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('models', {}, function () { +QUnit.module('attachment', {}, function () { +QUnit.module('attachment_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('create (txt)', async function (assert) { + assert.expect(9); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.strictEqual(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }), attachment); + assert.strictEqual(attachment.filename, "test.txt"); + assert.strictEqual(attachment.id, 750); + assert.notOk(attachment.isTemporary); + assert.strictEqual(attachment.mimetype, 'text/plain'); + assert.strictEqual(attachment.name, "test.txt"); +}); + +QUnit.test('displayName', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.strictEqual(attachment.displayName, "test.txt"); +}); + +QUnit.test('extension', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.strictEqual(attachment.extension, 'txt'); +}); + +QUnit.test('fileType', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ + id: 750, + })); + assert.strictEqual(attachment.fileType, 'text'); +}); + +QUnit.test('isTextFile', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.ok(attachment.isTextFile); +}); + +QUnit.test('isViewable', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.ok(attachment.isViewable); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js new file mode 100644 index 00000000..8a96946c --- /dev/null +++ b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js @@ -0,0 +1,59 @@ +odoo.define('mail/static/src/models/attachment_viewer/attachment_viewer.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class AttachmentViewer extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Close the attachment viewer by closing its linked dialog. + */ + close() { + const dialog = this.env.models['mail.dialog'].find(dialog => dialog.record === this); + if (dialog) { + dialog.delete(); + } + } + } + + AttachmentViewer.fields = { + /** + * Angle of the image. Changes when the user rotates it. + */ + angle: attr({ + default: 0, + }), + attachment: many2one('mail.attachment'), + attachments: many2many('mail.attachment', { + inverse: 'attachmentViewer', + }), + /** + * Determine whether the image is loading or not. Useful to diplay + * a spinner when loading image initially. + */ + isImageLoading: attr({ + default: false, + }), + /** + * Scale size of the image. Changes when user zooms in/out. + */ + scale: attr({ + default: 1, + }), + }; + + AttachmentViewer.modelName = 'mail.attachment_viewer'; + + return AttachmentViewer; +} + +registerNewModel('mail.attachment_viewer', factory); + +}); diff --git a/addons/mail/static/src/models/canned_response/canned_response.js b/addons/mail/static/src/models/canned_response/canned_response.js new file mode 100644 index 00000000..41e917d2 --- /dev/null +++ b/addons/mail/static/src/models/canned_response/canned_response.js @@ -0,0 +1,107 @@ +odoo.define('mail/static/src/models/canned_response/canned_response.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); +const { cleanSearchTerm } = require('mail/static/src/utils/utils.js'); + +function factory(dependencies) { + + class CannedResponse extends dependencies['mail.model'] { + + /** + * Fetches canned responses matching the given search term to extend the + * JS knowledge and to update the suggestion list accordingly. + * + * In practice all canned responses are already fetched at init so this + * method does nothing. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize and/or restrict + * result in the context of given thread + */ + static fetchSuggestions(searchTerm, { thread } = {}) {} + + /** + * Returns a sort function to determine the order of display of canned + * responses in the suggestion list. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize result in the + * context of given thread + * @returns {function} + */ + static getSuggestionSortFunction(searchTerm, { thread } = {}) { + const cleanedSearchTerm = cleanSearchTerm(searchTerm); + return (a, b) => { + const cleanedAName = cleanSearchTerm(a.source || ''); + const cleanedBName = cleanSearchTerm(b.source || ''); + if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) { + return -1; + } + if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) { + return 1; + } + if (cleanedAName < cleanedBName) { + return -1; + } + if (cleanedAName > cleanedBName) { + return 1; + } + return a.id - b.id; + }; + } + + /* + * Returns canned responses that match the given search term. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize and/or restrict + * result in the context of given thread + * @returns {[mail.canned_response[], mail.canned_response[]]} + */ + static searchSuggestions(searchTerm, { thread } = {}) { + const cleanedSearchTerm = cleanSearchTerm(searchTerm); + return [this.env.messaging.cannedResponses.filter(cannedResponse => + cleanSearchTerm(cannedResponse.source).includes(cleanedSearchTerm) + )]; + } + + /** + * Returns the text that identifies this canned response in a mention. + * + * @returns {string} + */ + getMentionText() { + return this.substitution; + } + + } + + CannedResponse.fields = { + id: attr(), + /** + * The keyword to use a specific canned response. + */ + source: attr(), + /** + * The canned response itself which will replace the keyword previously + * entered. + */ + substitution: attr(), + }; + + CannedResponse.modelName = 'mail.canned_response'; + + return CannedResponse; +} + +registerNewModel('mail.canned_response', factory); + +}); diff --git a/addons/mail/static/src/models/channel_command/channel_command.js b/addons/mail/static/src/models/channel_command/channel_command.js new file mode 100644 index 00000000..728acdb9 --- /dev/null +++ b/addons/mail/static/src/models/channel_command/channel_command.js @@ -0,0 +1,130 @@ +odoo.define('mail/static/src/models/channel_command/channel_command.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); +const { cleanSearchTerm } = require('mail/static/src/utils/utils.js'); + +function factory(dependencies) { + + class ChannelCommand extends dependencies['mail.model'] { + + /** + * Fetches channel commands matching the given search term to extend the + * JS knowledge and to update the suggestion list accordingly. + * + * In practice all channel commands are already fetched at init so this + * method does nothing. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize and/or restrict + * result in the context of given thread + */ + static fetchSuggestions(searchTerm, { thread } = {}) {} + + /** + * Returns a sort function to determine the order of display of channel + * commands in the suggestion list. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize result in the + * context of given thread + * @returns {function} + */ + static getSuggestionSortFunction(searchTerm, { thread } = {}) { + const cleanedSearchTerm = cleanSearchTerm(searchTerm); + return (a, b) => { + const isATypeSpecific = a.channel_types; + const isBTypeSpecific = b.channel_types; + if (isATypeSpecific && !isBTypeSpecific) { + return -1; + } + if (!isATypeSpecific && isBTypeSpecific) { + return 1; + } + const cleanedAName = cleanSearchTerm(a.name || ''); + const cleanedBName = cleanSearchTerm(b.name || ''); + if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) { + return -1; + } + if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) { + return 1; + } + if (cleanedAName < cleanedBName) { + return -1; + } + if (cleanedAName > cleanedBName) { + return 1; + } + return a.id - b.id; + }; + } + + /** + * Returns channel commands that match the given search term. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize and/or restrict + * result in the context of given thread + * @returns {[mail.channel_command[], mail.channel_command[]]} + */ + static searchSuggestions(searchTerm, { thread } = {}) { + if (thread.model !== 'mail.channel') { + // channel commands are channel specific + return [[]]; + } + const cleanedSearchTerm = cleanSearchTerm(searchTerm); + return [this.env.messaging.commands.filter(command => { + if (!cleanSearchTerm(command.name).includes(cleanedSearchTerm)) { + return false; + } + if (command.channel_types) { + return command.channel_types.includes(thread.channel_type); + } + return true; + })]; + } + + /** + * Returns the text that identifies this channel command in a mention. + * + * @returns {string} + */ + getMentionText() { + return this.name; + } + + } + + ChannelCommand.fields = { + /** + * Determines on which channel types `this` is available. + * Type of the channel (e.g. 'chat', 'channel' or 'groups') + * This field should contain an array when filtering is desired. + * Otherwise, it should be undefined when all types are allowed. + */ + channel_types: attr(), + /** + * The command that will be executed. + */ + help: attr(), + /** + * The keyword to use a specific command. + */ + name: attr(), + }; + + ChannelCommand.modelName = 'mail.channel_command'; + + return ChannelCommand; +} + +registerNewModel('mail.channel_command', factory); + +}); diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js new file mode 100644 index 00000000..49e22742 --- /dev/null +++ b/addons/mail/static/src/models/chat_window/chat_window.js @@ -0,0 +1,480 @@ +odoo.define('mail/static/src/models/chat_window/chat_window.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); + +function factory(dependencies) { + + class ChatWindow extends dependencies['mail.model'] { + + /** + * @override + */ + _created() { + const res = super._created(...arguments); + this._onShowHomeMenu.bind(this); + this._onHideHomeMenu.bind(this); + + this.env.messagingBus.on('hide_home_menu', this, this._onHideHomeMenu); + this.env.messagingBus.on('show_home_menu', this, this._onShowHomeMenu); + return res; + } + + /** + * @override + */ + _willDelete() { + this.env.messagingBus.off('hide_home_menu', this, this._onHideHomeMenu); + this.env.messagingBus.off('show_home_menu', this, this._onShowHomeMenu); + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Close this chat window. + * + * @param {Object} [param0={}] + * @param {boolean} [param0.notifyServer] + */ + close({ notifyServer } = {}) { + if (notifyServer === undefined) { + notifyServer = !this.env.messaging.device.isMobile; + } + const thread = this.thread; + this.delete(); + // Flux specific: 'closed' fold state should only be saved on the + // server when manually closing the chat window. Delete at destroy + // or sync from server value for example should not save the value. + if (thread && notifyServer) { + thread.notifyFoldStateToServer('closed'); + } + if (this.env.device.isMobile && !this.env.messaging.discuss.isOpen) { + // If we are in mobile and discuss is not open, it means the + // chat window was opened from the messaging menu. In that + // case it should be re-opened to simulate it was always + // there in the background. + this.env.messaging.messagingMenu.update({ isOpen: true }); + } + } + + expand() { + if (this.thread) { + this.thread.open({ expanded: true }); + } + } + + /** + * Programmatically auto-focus an existing chat window. + */ + focus() { + this.update({ isDoFocus: true }); + } + + focusNextVisibleUnfoldedChatWindow() { + const nextVisibleUnfoldedChatWindow = this._getNextVisibleUnfoldedChatWindow(); + if (nextVisibleUnfoldedChatWindow) { + nextVisibleUnfoldedChatWindow.focus(); + } + } + + focusPreviousVisibleUnfoldedChatWindow() { + const previousVisibleUnfoldedChatWindow = + this._getNextVisibleUnfoldedChatWindow({ reverse: true }); + if (previousVisibleUnfoldedChatWindow) { + previousVisibleUnfoldedChatWindow.focus(); + } + } + + /** + * @param {Object} [param0={}] + * @param {boolean} [param0.notifyServer] + */ + fold({ notifyServer } = {}) { + if (notifyServer === undefined) { + notifyServer = !this.env.messaging.device.isMobile; + } + this.update({ isFolded: true }); + // Flux specific: manually folding the chat window should save the + // new state on the server. + if (this.thread && notifyServer) { + this.thread.notifyFoldStateToServer('folded'); + } + } + + /** + * Makes this chat window active, which consists of making it visible, + * unfolding it, and focusing it. + * + * @param {Object} [options] + */ + makeActive(options) { + this.makeVisible(); + this.unfold(options); + this.focus(); + } + + /** + * Makes this chat window visible by swapping it with the last visible + * chat window, or do nothing if it is already visible. + */ + makeVisible() { + if (this.isVisible) { + return; + } + const lastVisible = this.manager.lastVisible; + this.manager.swap(this, lastVisible); + } + + /** + * Shift this chat window to the left on screen. + */ + shiftLeft() { + this.manager.shiftLeft(this); + } + + /** + * Shift this chat window to the right on screen. + */ + shiftRight() { + this.manager.shiftRight(this); + } + + /** + * @param {Object} [param0={}] + * @param {boolean} [param0.notifyServer] + */ + unfold({ notifyServer } = {}) { + if (notifyServer === undefined) { + notifyServer = !this.env.messaging.device.isMobile; + } + this.update({ isFolded: false }); + // Flux specific: manually opening the chat window should save the + // new state on the server. + if (this.thread && notifyServer) { + this.thread.notifyFoldStateToServer('open'); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {boolean} + */ + _computeHasNewMessageForm() { + return this.isVisible && !this.isFolded && !this.thread; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasShiftLeft() { + if (!this.manager) { + return false; + } + const allVisible = this.manager.allOrderedVisible; + const index = allVisible.findIndex(visible => visible === this); + if (index === -1) { + return false; + } + return index < allVisible.length - 1; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasShiftRight() { + if (!this.manager) { + return false; + } + const index = this.manager.allOrderedVisible.findIndex(visible => visible === this); + if (index === -1) { + return false; + } + return index > 0; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasThreadView() { + return this.isVisible && !this.isFolded && this.thread; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsFolded() { + const thread = this.thread; + if (thread) { + return thread.foldState === 'folded'; + } + return this.isFolded; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsVisible() { + if (!this.manager) { + return false; + } + return this.manager.allOrderedVisible.includes(this); + } + + /** + * @private + * @returns {string} + */ + _computeName() { + if (this.thread) { + return this.thread.displayName; + } + return this.env._t("New message"); + } + + /** + * @private + * @returns {integer|undefined} + */ + _computeVisibleIndex() { + if (!this.manager) { + return clear(); + } + const visible = this.manager.visual.visible; + const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId); + if (index === -1) { + return clear(); + } + return index; + } + + /** + * @private + * @returns {integer} + */ + _computeVisibleOffset() { + if (!this.manager) { + return 0; + } + const visible = this.manager.visual.visible; + const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId); + if (index === -1) { + return 0; + } + return visible[index].offset; + } + + /** + * Cycles to the next possible visible and unfolded chat window starting + * from the `currentChatWindow`, following the natural order based on the + * current text direction, and with the possibility to `reverse` based on + * the given parameter. + * + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.reverse=false] + * @returns {mail.chat_window|undefined} + */ + _getNextVisibleUnfoldedChatWindow({ reverse = false } = {}) { + const orderedVisible = this.manager.allOrderedVisible; + /** + * Return index of next visible chat window of a given visible chat + * window index. The direction of "next" chat window depends on + * `reverse` option. + * + * @param {integer} index + * @returns {integer} + */ + const _getNextIndex = index => { + const directionOffset = reverse ? 1 : -1; + let nextIndex = index + directionOffset; + if (nextIndex > orderedVisible.length - 1) { + nextIndex = 0; + } + if (nextIndex < 0) { + nextIndex = orderedVisible.length - 1; + } + return nextIndex; + }; + + const currentIndex = orderedVisible.findIndex(visible => visible === this); + let nextIndex = _getNextIndex(currentIndex); + let nextToFocus = orderedVisible[nextIndex]; + while (nextToFocus.isFolded) { + nextIndex = _getNextIndex(nextIndex); + nextToFocus = orderedVisible[nextIndex]; + } + return nextToFocus; + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * @private + */ + async _onHideHomeMenu() { + if (!this.threadView) { + return; + } + this.threadView.addComponentHint('home-menu-hidden'); + } + + /** + * @private + */ + async _onShowHomeMenu() { + if (!this.threadView) { + return; + } + this.threadView.addComponentHint('home-menu-shown'); + } + + } + + ChatWindow.fields = { + /** + * Determines whether "new message form" should be displayed. + */ + hasNewMessageForm: attr({ + compute: '_computeHasNewMessageForm', + dependencies: [ + 'isFolded', + 'isVisible', + 'thread', + ], + }), + hasShiftLeft: attr({ + compute: '_computeHasShiftLeft', + dependencies: ['managerAllOrderedVisible'], + default: false, + }), + hasShiftRight: attr({ + compute: '_computeHasShiftRight', + dependencies: ['managerAllOrderedVisible'], + default: false, + }), + /** + * Determines whether `this.thread` should be displayed. + */ + hasThreadView: attr({ + compute: '_computeHasThreadView', + dependencies: [ + 'isFolded', + 'isVisible', + 'thread', + ], + }), + /** + * Determine whether the chat window should be programmatically + * focused by observed component of chat window. Those components + * are responsible to unmark this record afterwards, otherwise + * any re-render will programmatically set focus again! + */ + isDoFocus: attr({ + default: false, + }), + /** + * States whether `this` is focused. Useful for visual clue. + */ + isFocused: attr({ + default: false, + }), + /** + * Determines whether `this` is folded. + */ + isFolded: attr({ + default: false, + }), + /** + * States whether `this` is visible or not. Should be considered + * read-only. Setting this value manually will not make it visible. + * @see `makeVisible` + */ + isVisible: attr({ + compute: '_computeIsVisible', + dependencies: [ + 'managerAllOrderedVisible', + ], + }), + manager: many2one('mail.chat_window_manager', { + inverse: 'chatWindows', + }), + managerAllOrderedVisible: one2many('mail.chat_window', { + related: 'manager.allOrderedVisible', + }), + managerVisual: attr({ + related: 'manager.visual', + }), + name: attr({ + compute: '_computeName', + dependencies: [ + 'thread', + 'threadDisplayName', + ], + }), + /** + * Determines the `mail.thread` that should be displayed by `this`. + * If no `mail.thread` is linked, `this` is considered "new message". + */ + thread: one2one('mail.thread', { + inverse: 'chatWindow', + }), + threadDisplayName: attr({ + related: 'thread.displayName', + }), + /** + * States the `mail.thread_view` displaying `this.thread`. + */ + threadView: one2one('mail.thread_view', { + related: 'threadViewer.threadView', + }), + /** + * Determines the `mail.thread_viewer` managing the display of `this.thread`. + */ + threadViewer: one2one('mail.thread_viewer', { + default: [['create']], + inverse: 'chatWindow', + isCausal: true, + }), + /** + * This field handle the "order" (index) of the visible chatWindow inside the UI. + * + * Using LTR, the right-most chat window has index 0, and the number is incrementing from right to left. + * Using RTL, the left-most chat window has index 0, and the number is incrementing from left to right. + */ + visibleIndex: attr({ + compute: '_computeVisibleIndex', + dependencies: [ + 'manager', + 'managerVisual', + ], + }), + visibleOffset: attr({ + compute: '_computeVisibleOffset', + dependencies: ['managerVisual'], + }), + }; + + ChatWindow.modelName = 'mail.chat_window'; + + return ChatWindow; +} + +registerNewModel('mail.chat_window', factory); + +}); diff --git a/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js new file mode 100644 index 00000000..fc367fef --- /dev/null +++ b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js @@ -0,0 +1,487 @@ +odoo.define('mail/static/src/models/chat_window_manager/chat_window_manager.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + const BASE_VISUAL = { + /** + * Amount of visible slots available for chat windows. + */ + availableVisibleSlots: 0, + /** + * Data related to the hidden menu. + */ + hidden: { + /** + * List of hidden docked chat windows. Useful to compute counter. + * Chat windows are ordered by their `chatWindows` order. + */ + chatWindowLocalIds: [], + /** + * Whether hidden menu is visible or not + */ + isVisible: false, + /** + * Offset of hidden menu starting point from the starting point + * of chat window manager. Makes only sense if it is visible. + */ + offset: 0, + }, + /** + * Data related to visible chat windows. Index determine order of + * docked chat windows. + * + * Value: + * + * { + * chatWindowLocalId, + * offset, + * } + * + * Offset is offset of starting point of docked chat window from + * starting point of dock chat window manager. Docked chat windows + * are ordered by their `chatWindows` order + */ + visible: [], + }; + + + class ChatWindowManager extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Close all chat windows. + * + */ + closeAll() { + const chatWindows = this.allOrdered; + for (const chatWindow of chatWindows) { + chatWindow.close(); + } + } + + closeHiddenMenu() { + this.update({ isHiddenMenuOpen: false }); + } + + /** + * Closes all chat windows related to the given thread. + * + * @param {mail.thread} thread + * @param {Object} [options] + */ + closeThread(thread, options) { + for (const chatWindow of this.chatWindows) { + if (chatWindow.thread === thread) { + chatWindow.close(options); + } + } + } + + openHiddenMenu() { + this.update({ isHiddenMenuOpen: true }); + } + + openNewMessage() { + let newMessageChatWindow = this.newMessageChatWindow; + if (!newMessageChatWindow) { + newMessageChatWindow = this.env.models['mail.chat_window'].create({ + manager: [['link', this]], + }); + } + newMessageChatWindow.makeActive(); + } + + /** + * @param {mail.thread} thread + * @param {Object} [param1={}] + * @param {boolean} [param1.isFolded=false] + * @param {boolean} [param1.makeActive=false] + * @param {boolean} [param1.notifyServer] + * @param {boolean} [param1.replaceNewMessage=false] + */ + openThread(thread, { + isFolded = false, + makeActive = false, + notifyServer, + replaceNewMessage = false + } = {}) { + if (notifyServer === undefined) { + notifyServer = !this.env.messaging.device.isMobile; + } + let chatWindow = this.chatWindows.find(chatWindow => + chatWindow.thread === thread + ); + if (!chatWindow) { + chatWindow = this.env.models['mail.chat_window'].create({ + isFolded, + manager: [['link', this]], + thread: [['link', thread]], + }); + } else { + chatWindow.update({ isFolded }); + } + if (replaceNewMessage && this.newMessageChatWindow) { + this.swap(chatWindow, this.newMessageChatWindow); + this.newMessageChatWindow.close(); + } + if (makeActive) { + // avoid double notify at this step, it will already be done at + // the end of the current method + chatWindow.makeActive({ notifyServer: false }); + } + // Flux specific: notify server of chat window being opened. + if (notifyServer) { + const foldState = chatWindow.isFolded ? 'folded' : 'open'; + thread.notifyFoldStateToServer(foldState); + } + } + + /** + * Shift provided chat window to the left on screen. + * + * @param {mail.chat_window} chatWindow + */ + shiftLeft(chatWindow) { + const chatWindows = this.allOrdered; + const index = chatWindows.findIndex(cw => cw === chatWindow); + if (index === chatWindows.length - 1) { + // already left-most + return; + } + const otherChatWindow = chatWindows[index + 1]; + const _newOrdered = [...this._ordered]; + _newOrdered[index] = otherChatWindow.localId; + _newOrdered[index + 1] = chatWindow.localId; + this.update({ _ordered: _newOrdered }); + chatWindow.focus(); + } + + /** + * Shift provided chat window to the right on screen. + * + * @param {mail.chat_window} chatWindow + */ + shiftRight(chatWindow) { + const chatWindows = this.allOrdered; + const index = chatWindows.findIndex(cw => cw === chatWindow); + if (index === 0) { + // already right-most + return; + } + const otherChatWindow = chatWindows[index - 1]; + const _newOrdered = [...this._ordered]; + _newOrdered[index] = otherChatWindow.localId; + _newOrdered[index - 1] = chatWindow.localId; + this.update({ _ordered: _newOrdered }); + chatWindow.focus(); + } + + /** + * @param {mail.chat_window} chatWindow1 + * @param {mail.chat_window} chatWindow2 + */ + swap(chatWindow1, chatWindow2) { + const ordered = this.allOrdered; + const index1 = ordered.findIndex(chatWindow => chatWindow === chatWindow1); + const index2 = ordered.findIndex(chatWindow => chatWindow === chatWindow2); + if (index1 === -1 || index2 === -1) { + return; + } + const _newOrdered = [...this._ordered]; + _newOrdered[index1] = chatWindow2.localId; + _newOrdered[index2] = chatWindow1.localId; + this.update({ _ordered: _newOrdered }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string[]} + */ + _compute_ordered() { + // remove unlinked chatWindows + const _ordered = this._ordered.filter(chatWindowLocalId => + this.chatWindows.includes(this.env.models['mail.chat_window'].get(chatWindowLocalId)) + ); + // add linked chatWindows + for (const chatWindow of this.chatWindows) { + if (!_ordered.includes(chatWindow.localId)) { + _ordered.push(chatWindow.localId); + } + } + return _ordered; + } + + /** + * // FIXME: dependent on implementation that uses arbitrary order in relations!! + * + * @private + * @returns {mail.chat_window} + */ + _computeAllOrdered() { + return [['replace', this._ordered.map(chatWindowLocalId => + this.env.models['mail.chat_window'].get(chatWindowLocalId) + )]]; + } + + /** + * @private + * @returns {mail.chat_window[]} + */ + _computeAllOrderedHidden() { + return [['replace', this.visual.hidden.chatWindowLocalIds.map(chatWindowLocalId => + this.env.models['mail.chat_window'].get(chatWindowLocalId) + )]]; + } + + /** + * @private + * @returns {mail.chat_window[]} + */ + _computeAllOrderedVisible() { + return [['replace', this.visual.visible.map(({ chatWindowLocalId }) => + this.env.models['mail.chat_window'].get(chatWindowLocalId) + )]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasHiddenChatWindows() { + return this.allOrderedHidden.length > 0; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasVisibleChatWindows() { + return this.allOrderedVisible.length > 0; + } + + /** + * @private + * @returns {mail.chat_window|undefined} + */ + _computeLastVisible() { + const { length: l, [l - 1]: lastVisible } = this.allOrderedVisible; + if (!lastVisible) { + return [['unlink']]; + } + return [['link', lastVisible]]; + } + + /** + * @private + * @returns {mail.chat_window|undefined} + */ + _computeNewMessageChatWindow() { + const chatWindow = this.allOrdered.find(chatWindow => !chatWindow.thread); + if (!chatWindow) { + return [['unlink']]; + } + return [['link', chatWindow]]; + } + + /** + * @private + * @returns {integer} + */ + _computeUnreadHiddenConversationAmount() { + const allHiddenWithThread = this.allOrderedHidden.filter( + chatWindow => chatWindow.thread + ); + let amount = 0; + for (const chatWindow of allHiddenWithThread) { + if (chatWindow.thread.localMessageUnreadCounter > 0) { + amount++; + } + } + return amount; + } + + /** + * @private + * @returns {Object} + */ + _computeVisual() { + let visual = JSON.parse(JSON.stringify(BASE_VISUAL)); + if (!this.env.messaging) { + return visual; + } + const device = this.env.messaging.device; + const discuss = this.env.messaging.discuss; + const BETWEEN_GAP_WIDTH = 5; + const CHAT_WINDOW_WIDTH = 325; + const END_GAP_WIDTH = device.isMobile ? 0 : 10; + const GLOBAL_WINDOW_WIDTH = device.globalWindowInnerWidth; + const HIDDEN_MENU_WIDTH = 200; // max width, including width of dropup list items + const START_GAP_WIDTH = device.isMobile ? 0 : 10; + const chatWindows = this.allOrdered; + if (!device.isMobile && discuss.isOpen) { + return visual; + } + if (!chatWindows.length) { + return visual; + } + const relativeGlobalWindowWidth = GLOBAL_WINDOW_WIDTH - START_GAP_WIDTH - END_GAP_WIDTH; + let maxAmountWithoutHidden = Math.floor( + relativeGlobalWindowWidth / (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH)); + let maxAmountWithHidden = Math.floor( + (relativeGlobalWindowWidth - HIDDEN_MENU_WIDTH - BETWEEN_GAP_WIDTH) / + (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH)); + if (device.isMobile) { + maxAmountWithoutHidden = 1; + maxAmountWithHidden = 1; + } + if (chatWindows.length <= maxAmountWithoutHidden) { + // all visible + for (let i = 0; i < chatWindows.length; i++) { + const chatWindowLocalId = chatWindows[i].localId; + const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH); + visual.visible.push({ chatWindowLocalId, offset }); + } + visual.availableVisibleSlots = maxAmountWithoutHidden; + } else if (maxAmountWithHidden > 0) { + // some visible, some hidden + for (let i = 0; i < maxAmountWithHidden; i++) { + const chatWindowLocalId = chatWindows[i].localId; + const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH); + visual.visible.push({ chatWindowLocalId, offset }); + } + if (chatWindows.length > maxAmountWithHidden) { + visual.hidden.isVisible = !device.isMobile; + visual.hidden.offset = visual.visible[maxAmountWithHidden - 1].offset + + CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH; + } + for (let j = maxAmountWithHidden; j < chatWindows.length; j++) { + visual.hidden.chatWindowLocalIds.push(chatWindows[j].localId); + } + visual.availableVisibleSlots = maxAmountWithHidden; + } else { + // all hidden + visual.hidden.isVisible = !device.isMobile; + visual.hidden.offset = START_GAP_WIDTH; + visual.hidden.chatWindowLocalIds.concat(chatWindows.map(chatWindow => chatWindow.localId)); + console.warn('cannot display any visible chat windows (screen is too small)'); + visual.availableVisibleSlots = 0; + } + return visual; + } + + } + + ChatWindowManager.fields = { + /** + * List of ordered chat windows (list of local ids) + */ + _ordered: attr({ + compute: '_compute_ordered', + default: [], + dependencies: [ + 'chatWindows', + ], + }), + // FIXME: dependent on implementation that uses arbitrary order in relations!! + allOrdered: one2many('mail.chat_window', { + compute: '_computeAllOrdered', + dependencies: [ + '_ordered', + ], + }), + allOrderedThread: one2many('mail.thread', { + related: 'allOrdered.thread', + }), + allOrderedHidden: one2many('mail.chat_window', { + compute: '_computeAllOrderedHidden', + dependencies: ['visual'], + }), + allOrderedHiddenThread: one2many('mail.thread', { + related: 'allOrderedHidden.thread', + }), + allOrderedHiddenThreadMessageUnreadCounter: attr({ + related: 'allOrderedHiddenThread.localMessageUnreadCounter', + }), + allOrderedVisible: one2many('mail.chat_window', { + compute: '_computeAllOrderedVisible', + dependencies: ['visual'], + }), + chatWindows: one2many('mail.chat_window', { + inverse: 'manager', + isCausal: true, + }), + device: one2one('mail.device', { + related: 'messaging.device', + }), + deviceGlobalWindowInnerWidth: attr({ + related: 'device.globalWindowInnerWidth', + }), + deviceIsMobile: attr({ + related: 'device.isMobile', + }), + discuss: one2one('mail.discuss', { + related: 'messaging.discuss', + }), + discussIsOpen: attr({ + related: 'discuss.isOpen', + }), + hasHiddenChatWindows: attr({ + compute: '_computeHasHiddenChatWindows', + dependencies: ['allOrderedHidden'], + }), + hasVisibleChatWindows: attr({ + compute: '_computeHasVisibleChatWindows', + dependencies: ['allOrderedVisible'], + }), + isHiddenMenuOpen: attr({ + default: false, + }), + lastVisible: many2one('mail.chat_window', { + compute: '_computeLastVisible', + dependencies: ['allOrderedVisible'], + }), + messaging: one2one('mail.messaging', { + inverse: 'chatWindowManager', + }), + newMessageChatWindow: one2one('mail.chat_window', { + compute: '_computeNewMessageChatWindow', + dependencies: [ + 'allOrdered', + 'allOrderedThread', + ], + }), + unreadHiddenConversationAmount: attr({ + compute: '_computeUnreadHiddenConversationAmount', + dependencies: ['allOrderedHiddenThreadMessageUnreadCounter'], + }), + visual: attr({ + compute: '_computeVisual', + default: BASE_VISUAL, + dependencies: [ + 'allOrdered', + 'deviceGlobalWindowInnerWidth', + 'deviceIsMobile', + 'discussIsOpen', + ], + }), + }; + + ChatWindowManager.modelName = 'mail.chat_window_manager'; + + return ChatWindowManager; +} + +registerNewModel('mail.chat_window_manager', factory); + +}); diff --git a/addons/mail/static/src/models/chatter/chatter.js b/addons/mail/static/src/models/chatter/chatter.js new file mode 100644 index 00000000..84f611eb --- /dev/null +++ b/addons/mail/static/src/models/chatter/chatter.js @@ -0,0 +1,334 @@ +odoo.define('mail/static/src/models/chatter/chatter.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + const getThreadNextTemporaryId = (function () { + let tmpId = 0; + return () => { + tmpId -= 1; + return tmpId; + }; + })(); + + const getMessageNextTemporaryId = (function () { + let tmpId = 0; + return () => { + tmpId -= 1; + return tmpId; + }; + })(); + + class Chatter extends dependencies['mail.model'] { + + /** + * @override + */ + _willDelete() { + this._stopAttachmentsLoading(); + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + focus() { + this.update({ isDoFocus: true }); + } + + async refresh() { + if (this.hasActivities) { + this.thread.refreshActivities(); + } + if (this.hasFollowers) { + this.thread.refreshFollowers(); + this.thread.fetchAndUpdateSuggestedRecipients(); + } + if (this.hasMessageList) { + this.thread.refresh(); + } + } + + showLogNote() { + this.update({ isComposerVisible: true }); + this.thread.composer.update({ isLog: true }); + this.focus(); + } + + showSendMessage() { + this.update({ isComposerVisible: true }); + this.thread.composer.update({ isLog: false }); + this.focus(); + } + + toggleActivityBoxVisibility() { + this.update({ isActivityBoxVisible: !this.isActivityBoxVisible }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {boolean} + */ + _computeHasThreadView() { + return this.thread && this.hasMessageList; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsDisabled() { + return !this.thread || this.thread.isTemporary; + } + + /** + * @private + */ + _onThreadIdOrThreadModelChanged() { + if (this.threadId) { + if (this.thread && this.thread.isTemporary) { + this.thread.delete(); + } + this.update({ + isAttachmentBoxVisible: this.isAttachmentBoxVisibleInitially, + thread: [['insert', { + // If the thread was considered to have the activity + // mixin once, it will have it forever. + hasActivities: this.hasActivities ? true : undefined, + id: this.threadId, + model: this.threadModel, + }]], + }); + if (this.hasActivities) { + this.thread.refreshActivities(); + } + if (this.hasFollowers) { + this.thread.refreshFollowers(); + this.thread.fetchAndUpdateSuggestedRecipients(); + } + if (this.hasMessageList) { + this.thread.refresh(); + } + } else if (!this.thread || !this.thread.isTemporary) { + const currentPartner = this.env.messaging.currentPartner; + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: this.env._t("Creating a new record..."), + id: getMessageNextTemporaryId(), + isTemporary: true, + }); + const nextId = getThreadNextTemporaryId(); + this.update({ + isAttachmentBoxVisible: false, + thread: [['insert', { + areAttachmentsLoaded: true, + id: nextId, + isTemporary: true, + model: this.threadModel, + }]], + }); + for (const cache of this.thread.caches) { + cache.update({ messages: [['link', message]] }); + } + } + } + + /** + * @private + */ + _onThreadIsLoadingAttachmentsChanged() { + if (!this.thread || !this.thread.isLoadingAttachments) { + this._stopAttachmentsLoading(); + return; + } + if (this._isPreparingAttachmentsLoading || this.isShowingAttachmentsLoading) { + return; + } + this._prepareAttachmentsLoading(); + } + + /** + * @private + */ + _prepareAttachmentsLoading() { + this._isPreparingAttachmentsLoading = true; + this._attachmentsLoaderTimeout = this.env.browser.setTimeout(() => { + this.update({ isShowingAttachmentsLoading: true }); + this._isPreparingAttachmentsLoading = false; + }, this.env.loadingBaseDelayDuration); + } + + /** + * @private + */ + _stopAttachmentsLoading() { + this.env.browser.clearTimeout(this._attachmentsLoaderTimeout); + this._attachmentsLoaderTimeout = null; + this.update({ isShowingAttachmentsLoading: false }); + this._isPreparingAttachmentsLoading = false; + } + + } + + Chatter.fields = { + composer: many2one('mail.composer', { + related: 'thread.composer', + }), + context: attr({ + default: {}, + }), + /** + * Determines whether `this` should display an activity box. + */ + hasActivities: attr({ + default: true, + }), + hasExternalBorder: attr({ + default: true, + }), + /** + * Determines whether `this` should display followers menu. + */ + hasFollowers: attr({ + default: true, + }), + /** + * Determines whether `this` should display a message list. + */ + hasMessageList: attr({ + default: true, + }), + /** + * Whether the message list should manage its scroll. + * In particular, when the chatter is on the form view's side, + * then the scroll is managed by the message list. + * Also, the message list shoud not manage the scroll if it shares it + * with the rest of the page. + */ + hasMessageListScrollAdjust: attr({ + default: false, + }), + /** + * Determines whether `this.thread` should be displayed. + */ + hasThreadView: attr({ + compute: '_computeHasThreadView', + dependencies: [ + 'hasMessageList', + 'thread', + ], + }), + hasTopbarCloseButton: attr({ + default: false, + }), + isActivityBoxVisible: attr({ + default: true, + }), + /** + * Determiners whether the attachment box is currently visible. + */ + isAttachmentBoxVisible: attr({ + default: false, + }), + /** + * Determiners whether the attachment box is visible initially. + */ + isAttachmentBoxVisibleInitially: attr({ + default: false, + }), + isComposerVisible: attr({ + default: false, + }), + isDisabled: attr({ + compute: '_computeIsDisabled', + default: false, + dependencies: [ + 'threadIsTemporary', + ], + }), + /** + * Determine whether this chatter should be focused at next render. + */ + isDoFocus: attr({ + default: false, + }), + isShowingAttachmentsLoading: attr({ + default: false, + }), + /** + * Not a real field, used to trigger its compute method when one of the + * dependencies changes. + */ + onThreadIdOrThreadModelChanged: attr({ + compute: '_onThreadIdOrThreadModelChanged', + dependencies: [ + 'threadId', + 'threadModel', + ], + }), + /** + * Not a real field, used to trigger its compute method when one of the + * dependencies changes. + */ + onThreadIsLoadingAttachmentsChanged: attr({ + compute: '_onThreadIsLoadingAttachmentsChanged', + dependencies: [ + 'threadIsLoadingAttachments', + ], + }), + /** + * Determines the `mail.thread` that should be displayed by `this`. + */ + thread: many2one('mail.thread'), + /** + * Determines the id of the thread that will be displayed by `this`. + */ + threadId: attr(), + /** + * Serves as compute dependency. + */ + threadIsLoadingAttachments: attr({ + related: 'thread.isLoadingAttachments', + }), + /** + * Serves as compute dependency. + */ + threadIsTemporary: attr({ + related: 'thread.isTemporary', + }), + /** + * Determines the model of the thread that will be displayed by `this`. + */ + threadModel: attr(), + /** + * States the `mail.thread_view` displaying `this.thread`. + */ + threadView: one2one('mail.thread_view', { + related: 'threadViewer.threadView', + }), + /** + * Determines the `mail.thread_viewer` managing the display of `this.thread`. + */ + threadViewer: one2one('mail.thread_viewer', { + default: [['create']], + inverse: 'chatter', + isCausal: true, + }), + }; + + Chatter.modelName = 'mail.chatter'; + + return Chatter; +} + +registerNewModel('mail.chatter', factory); + +}); diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js new file mode 100644 index 00000000..d20520e3 --- /dev/null +++ b/addons/mail/static/src/models/composer/composer.js @@ -0,0 +1,1435 @@ +odoo.define('mail/static/src/models/composer/composer.js', function (require) { +'use strict'; + +const emojis = require('mail.emojis'); +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); +const mailUtils = require('mail.utils'); + +const { + addLink, + escapeAndCompactTextContent, + parseAndTransform, +} = require('mail.utils'); + +function factory(dependencies) { + + class Composer extends dependencies['mail.model'] { + + /** + * @override + */ + _willCreate() { + const res = super._willCreate(...arguments); + /** + * Determines whether there is a mention RPC currently in progress. + * Useful to queue a new call if there is already one pending. + */ + this._hasMentionRpcInProgress = false; + /** + * Determines the next function to execute after the current mention + * RPC is done, if any. + */ + this._nextMentionRpcFunction = undefined; + return res; + } + + /** + * @override + */ + _willDelete() { + // Clears the mention queue on deleting the record to prevent + // unnecessary RPC. + this._nextMentionRpcFunction = undefined; + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Closes the suggestion list. + */ + closeSuggestions() { + this.update({ suggestionDelimiterPosition: clear() }); + } + + /** + * @deprecated what this method used to do is now automatically computed + * based on composer state + */ + async detectSuggestionDelimiter() {} + + /** + * Hides the composer, which only makes sense if the composer is + * currently used as a Discuss Inbox reply composer. + */ + discard() { + if (this.discussAsReplying) { + this.discussAsReplying.clearReplyingToMessage(); + } + } + + /** + * Focus this composer and remove focus from all others. + * Focus is a global concern, it makes no sense to have multiple composers focused at the + * same time. + */ + focus() { + const allComposers = this.env.models['mail.composer'].all(); + for (const otherComposer of allComposers) { + if (otherComposer !== this && otherComposer.hasFocus) { + otherComposer.update({ hasFocus: false }); + } + } + this.update({ hasFocus: true }); + } + + /** + * Inserts text content in text input based on selection. + * + * @param {string} content + */ + insertIntoTextInput(content) { + const partA = this.textInputContent.slice(0, this.textInputCursorStart); + const partB = this.textInputContent.slice( + this.textInputCursorEnd, + this.textInputContent.length + ); + let suggestionDelimiterPosition = this.suggestionDelimiterPosition; + if ( + suggestionDelimiterPosition !== undefined && + suggestionDelimiterPosition >= this.textInputCursorStart + ) { + suggestionDelimiterPosition = suggestionDelimiterPosition + content.length; + } + this.update({ + isLastStateChangeProgrammatic: true, + suggestionDelimiterPosition, + textInputContent: partA + content + partB, + textInputCursorEnd: this.textInputCursorStart + content.length, + textInputCursorStart: this.textInputCursorStart + content.length, + }); + } + + insertSuggestion() { + const cursorPosition = this.textInputCursorStart; + let textLeft = this.textInputContent.substring( + 0, + this.suggestionDelimiterPosition + 1 + ); + let textRight = this.textInputContent.substring( + cursorPosition, + this.textInputContent.length + ); + if (this.suggestionDelimiter === ':') { + textLeft = this.textInputContent.substring( + 0, + this.suggestionDelimiterPosition + ); + textRight = this.textInputContent.substring( + cursorPosition, + this.textInputContent.length + ); + } + const recordReplacement = this.activeSuggestedRecord.getMentionText(); + const updateData = { + isLastStateChangeProgrammatic: true, + textInputContent: textLeft + recordReplacement + ' ' + textRight, + textInputCursorEnd: textLeft.length + recordReplacement.length + 1, + textInputCursorStart: textLeft.length + recordReplacement.length + 1, + }; + // Specific cases for channel and partner mentions: the message with + // the mention will appear in the target channel, or be notified to + // the target partner. + switch (this.activeSuggestedRecord.constructor.modelName) { + case 'mail.thread': + Object.assign(updateData, { mentionedChannels: [['link', this.activeSuggestedRecord]] }); + break; + case 'mail.partner': + Object.assign(updateData, { mentionedPartners: [['link', this.activeSuggestedRecord]] }); + break; + } + this.update(updateData); + } + + /** + * @private + * @returns {mail.partner[]} + */ + _computeRecipients() { + const recipients = [...this.mentionedPartners]; + if (this.thread && !this.isLog) { + for (const recipient of this.thread.suggestedRecipientInfoList) { + if (recipient.partner && recipient.isSelected) { + recipients.push(recipient.partner); + } + } + } + return [['replace', recipients]]; + } + + /** + * Open the full composer modal. + */ + async openFullComposer() { + const attachmentIds = this.attachments.map(attachment => attachment.id); + + const context = { + default_attachment_ids: attachmentIds, + default_body: mailUtils.escapeAndCompactTextContent(this.textInputContent), + default_is_log: this.isLog, + default_model: this.thread.model, + default_partner_ids: this.recipients.map(partner => partner.id), + default_res_id: this.thread.id, + mail_post_autofollow: true, + }; + + const action = { + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: context, + }; + const options = { + on_close: () => { + if (!this.exists()) { + return; + } + this._reset(); + this.thread.loadNewMessages(); + }, + }; + await this.env.bus.trigger('do-action', { action, options }); + } + + /** + * Post a message in provided composer's thread based on current composer fields values. + */ + async postMessage() { + const thread = this.thread; + this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true }); + const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent); + let body = escapedAndCompactContent.replace(/ /g, ' ').trim(); + // This message will be received from the mail composer as html content + // subtype but the urls will not be linkified. If the mail composer + // takes the responsibility to linkify the urls we end up with double + // linkification a bit everywhere. Ideally we want to keep the content + // as text internally and only make html enrichment at display time but + // the current design makes this quite hard to do. + body = this._generateMentionsLinks(body); + body = parseAndTransform(body, addLink); + body = this._generateEmojisOnHtml(body); + let postData = { + attachment_ids: this.attachments.map(attachment => attachment.id), + body, + channel_ids: this.mentionedChannels.map(channel => channel.id), + message_type: 'comment', + partner_ids: this.recipients.map(partner => partner.id), + }; + if (this.subjectContent) { + postData.subject = this.subjectContent; + } + try { + let messageId; + this.update({ isPostingMessage: true }); + if (thread.model === 'mail.channel') { + const command = this._getCommandFromText(body); + Object.assign(postData, { + subtype_xmlid: 'mail.mt_comment', + }); + if (command) { + messageId = await this.async(() => this.env.models['mail.thread'].performRpcExecuteCommand({ + channelId: thread.id, + command: command.name, + postData, + })); + } else { + messageId = await this.async(() => + this.env.models['mail.thread'].performRpcMessagePost({ + postData, + threadId: thread.id, + threadModel: thread.model, + }) + ); + } + } else { + Object.assign(postData, { + subtype_xmlid: this.isLog ? 'mail.mt_note' : 'mail.mt_comment', + }); + if (!this.isLog) { + postData.context = { + mail_post_autofollow: true, + }; + } + messageId = await this.async(() => + this.env.models['mail.thread'].performRpcMessagePost({ + postData, + threadId: thread.id, + threadModel: thread.model, + }) + ); + const [messageData] = await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'message_format', + args: [[messageId]], + }, { shadow: true })); + this.env.models['mail.message'].insert(Object.assign( + {}, + this.env.models['mail.message'].convertData(messageData), + { + originThread: [['insert', { + id: thread.id, + model: thread.model, + }]], + }) + ); + thread.loadNewMessages(); + } + for (const threadView of this.thread.threadViews) { + // Reset auto scroll to be able to see the newly posted message. + threadView.update({ hasAutoScrollOnMessageReceived: true }); + } + thread.refreshFollowers(); + thread.fetchAndUpdateSuggestedRecipients(); + this._reset(); + } finally { + this.update({ isPostingMessage: false }); + } + } + + /** + * Called when current partner is inserting some input in composer. + * Useful to notify current partner is currently typing something in the + * composer of this thread to all other members. + */ + handleCurrentPartnerIsTyping() { + if (!this.thread) { + return; + } + if ( + this.suggestionModelName === 'mail.channel_command' || + this._getCommandFromText(this.textInputContent) + ) { + return; + } + if (this.thread.typingMembers.includes(this.env.messaging.currentPartner)) { + this.thread.refreshCurrentPartnerIsTyping(); + } else { + this.thread.registerCurrentPartnerIsTyping(); + } + } + + /** + * Sets the first suggestion as active. Main and extra records are + * considered together. + */ + setFirstSuggestionActive() { + const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords); + const firstRecord = suggestedRecords[0]; + this.update({ activeSuggestedRecord: [['link', firstRecord]] }); + } + + /** + * Sets the last suggestion as active. Main and extra records are + * considered together. + */ + setLastSuggestionActive() { + const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords); + const { length, [length - 1]: lastRecord } = suggestedRecords; + this.update({ activeSuggestedRecord: [['link', lastRecord]] }); + } + + /** + * Sets the next suggestion as active. Main and extra records are + * considered together. + */ + setNextSuggestionActive() { + const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords); + const activeElementIndex = suggestedRecords.findIndex( + suggestion => suggestion === this.activeSuggestedRecord + ); + if (activeElementIndex === suggestedRecords.length - 1) { + // loop when reaching the end of the list + this.setFirstSuggestionActive(); + return; + } + const nextRecord = suggestedRecords[activeElementIndex + 1]; + this.update({ activeSuggestedRecord: [['link', nextRecord]] }); + } + + /** + * Sets the previous suggestion as active. Main and extra records are + * considered together. + */ + setPreviousSuggestionActive() { + const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords); + const activeElementIndex = suggestedRecords.findIndex( + suggestion => suggestion === this.activeSuggestedRecord + ); + if (activeElementIndex === 0) { + // loop when reaching the start of the list + this.setLastSuggestionActive(); + return; + } + const previousRecord = suggestedRecords[activeElementIndex - 1]; + this.update({ activeSuggestedRecord: [['link', previousRecord]] }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @deprecated + * @private + * @returns {mail.canned_response} + */ + _computeActiveSuggestedCannedResponse() { + if (this.suggestionDelimiter === ':' && this.activeSuggestedRecord) { + return [['link', this.activeSuggestedRecord]]; + } + return [['unlink']]; + } + + /** + * @deprecated + * @private + * @returns {mail.thread} + */ + _computeActiveSuggestedChannel() { + if (this.suggestionDelimiter === '#' && this.activeSuggestedRecord) { + return [['link', this.activeSuggestedRecord]]; + } + return [['unlink']]; + } + + /** + * @deprecated + * @private + * @returns {mail.channel_command} + */ + _computeActiveSuggestedChannelCommand() { + if (this.suggestionDelimiter === '/' && this.activeSuggestedRecord) { + return [['link', this.activeSuggestedRecord]]; + } + return [['unlink']]; + } + + /** + * @deprecated + * @private + * @returns {mail.partner} + */ + _computeActiveSuggestedPartner() { + if (this.suggestionDelimiter === '@' && this.activeSuggestedRecord) { + return [['link', this.activeSuggestedRecord]]; + } + return [['unlink']]; + } + + /** + * Clears the active suggested record on closing mentions or adapt it if + * the active current record is no longer part of the suggestions. + * + * @private + * @returns {mail.model} + */ + _computeActiveSuggestedRecord() { + if ( + this.mainSuggestedRecords.length === 0 && + this.extraSuggestedRecords.length === 0 + ) { + return [['unlink']]; + } + if ( + this.mainSuggestedRecords.includes(this.activeSuggestedRecord) || + this.extraSuggestedRecords.includes(this.activeSuggestedRecord) + ) { + return; + } + const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords); + const firstRecord = suggestedRecords[0]; + return [['link', firstRecord]]; + } + + /** + * @deprecated + * @private + * @returns {string} + */ + _computeActiveSuggestedRecordName() { + switch (this.suggestionDelimiter) { + case '@': + return "activeSuggestedPartner"; + case ':': + return "activeSuggestedCannedResponse"; + case '/': + return "activeSuggestedChannelCommand"; + case '#': + return "activeSuggestedChannel"; + default: + return clear(); + } + } + + /** + * @private + * @returns {boolean} + */ + _computeCanPostMessage() { + if (!this.textInputContent && this.attachments.length === 0) { + return false; + } + return !this.hasUploadingAttachment && !this.isPostingMessage; + } + + /** + * @deprecated + * @private + * @returns {mail.partner[]} + */ + _computeExtraSuggestedPartners() { + if (this.suggestionDelimiter === '@') { + return [['replace', this.extraSuggestedRecords]]; + } + return [['unlink-all']]; + } + + /** + * Clears the extra suggested record on closing mentions, and ensures + * the extra list does not contain any element already present in the + * main list, which is a requirement for the navigation process. + * + * @private + * @returns {mail.model[]} + */ + _computeExtraSuggestedRecords() { + if (this.suggestionDelimiterPosition === undefined) { + return [['unlink-all']]; + } + return [['unlink', this.mainSuggestedRecords]]; + } + + /** + * @deprecated + * @private + * @returns {mail.model[]} + */ + _computeExtraSuggestedRecordsList() { + return this.extraSuggestedRecords; + } + + /** + * @deprecated + * @private + * @returns {string} + */ + _computeExtraSuggestedRecordsListName() { + if (this.suggestionDelimiter === '@') { + return "extraSuggestedPartners"; + } + return clear(); + } + + /** + * @private + * @return {boolean} + */ + _computeHasSuggestions() { + return this.mainSuggestedRecords.length > 0 || this.extraSuggestedRecords.length > 0; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasUploadingAttachment() { + return this.attachments.some(attachment => attachment.isTemporary); + } + + /** + * @deprecated + * @private + * @returns {mail.model[]} + */ + _computeMainSuggestedPartners() { + if (this.suggestionDelimiter === '@') { + return [['replace', this.mainSuggestedRecords]]; + } + return [['unlink-all']]; + } + + /** + * Clears the main suggested record on closing mentions. + * + * @private + * @returns {mail.model[]} + */ + _computeMainSuggestedRecords() { + if (this.suggestionDelimiterPosition === undefined) { + return [['unlink-all']]; + } + } + + /** + * @deprecated + * @private + * @returns {mail.model[]} + */ + _computeMainSuggestedRecordsList() { + return this.mainSuggestedRecords; + } + + /** + * @deprecated + * @private + * @returns {string} + */ + _computeMainSuggestedRecordsListName() { + switch (this.suggestionDelimiter) { + case '@': + return "mainSuggestedPartners"; + case ':': + return "suggestedCannedResponses"; + case '/': + return "suggestedChannelCommands"; + case '#': + return "suggestedChannels"; + default: + return clear(); + } + } + + /** + * Detects if mentioned partners are still in the composer text input content + * and removes them if not. + * + * @private + * @returns {mail.partner[]} + */ + _computeMentionedPartners() { + const unmentionedPartners = []; + // ensure the same mention is not used multiple times if multiple + // partners have the same name + const namesIndex = {}; + for (const partner of this.mentionedPartners) { + const fromIndex = namesIndex[partner.name] !== undefined + ? namesIndex[partner.name] + 1 : + 0; + const index = this.textInputContent.indexOf(`@${partner.name}`, fromIndex); + if (index !== -1) { + namesIndex[partner.name] = index; + } else { + unmentionedPartners.push(partner); + } + } + return [['unlink', unmentionedPartners]]; + } + + /** + * Detects if mentioned channels are still in the composer text input content + * and removes them if not. + * + * @private + * @returns {mail.partner[]} + */ + _computeMentionedChannels() { + const unmentionedChannels = []; + // ensure the same mention is not used multiple times if multiple + // channels have the same name + const namesIndex = {}; + for (const channel of this.mentionedChannels) { + const fromIndex = namesIndex[channel.name] !== undefined + ? namesIndex[channel.name] + 1 : + 0; + const index = this.textInputContent.indexOf(`#${channel.name}`, fromIndex); + if (index !== -1) { + namesIndex[channel.name] = index; + } else { + unmentionedChannels.push(channel); + } + } + return [['unlink', unmentionedChannels]]; + } + + /** + * @deprecated + * @private + * @returns {mail.canned_response[]} + */ + _computeSuggestedCannedResponses() { + if (this.suggestionDelimiter === ':') { + return [['replace', this.mainSuggestedRecords]]; + } + return [['unlink-all']]; + } + + /** + * @deprecated + * @private + * @returns {mail.thread[]} + */ + _computeSuggestedChannels() { + if (this.suggestionDelimiter === '#') { + return [['replace', this.mainSuggestedRecords]]; + } + return [['unlink-all']]; + } + + /** + * @private + * @returns {string} + */ + _computeSuggestionDelimiter() { + if ( + this.suggestionDelimiterPosition === undefined || + this.suggestionDelimiterPosition >= this.textInputContent.length + ) { + return clear(); + } + return this.textInputContent[this.suggestionDelimiterPosition]; + } + + /** + * @private + * @returns {integer} + */ + _computeSuggestionDelimiterPosition() { + if (this.textInputCursorStart !== this.textInputCursorEnd) { + // avoid interfering with multi-char selection + return clear(); + } + const candidatePositions = []; + // keep the current delimiter if it is still valid + if ( + this.suggestionDelimiterPosition !== undefined && + this.suggestionDelimiterPosition < this.textInputCursorStart + ) { + candidatePositions.push(this.suggestionDelimiterPosition); + } + // consider the char before the current cursor position if the + // current delimiter is no longer valid (or if there is none) + if (this.textInputCursorStart > 0) { + candidatePositions.push(this.textInputCursorStart - 1); + } + const suggestionDelimiters = ['@', ':', '#', '/']; + for (const candidatePosition of candidatePositions) { + if ( + candidatePosition < 0 || + candidatePosition >= this.textInputContent.length + ) { + continue; + } + const candidateChar = this.textInputContent[candidatePosition]; + if (candidateChar === '/' && candidatePosition !== 0) { + continue; + } + if (!suggestionDelimiters.includes(candidateChar)) { + continue; + } + const charBeforeCandidate = this.textInputContent[candidatePosition - 1]; + if (charBeforeCandidate && !/\s/.test(charBeforeCandidate)) { + continue; + } + return candidatePosition; + } + return clear(); + } + + /** + * @deprecated + * @private + * @returns {mail.channel_command[]} + */ + _computeSuggestedChannelCommands() { + if (this.suggestionDelimiter === '/') { + return [['replace', this.mainSuggestedRecords]]; + } + return [['unlink-all']]; + } + + /** + * @private + * @returns {string} + */ + _computeSuggestionModelName() { + switch (this.suggestionDelimiter) { + case '@': + return 'mail.partner'; + case ':': + return 'mail.canned_response'; + case '/': + return 'mail.channel_command'; + case '#': + return 'mail.thread'; + default: + return clear(); + } + } + + /** + * @private + * @returns {string} + */ + _computeSuggestionSearchTerm() { + if ( + this.suggestionDelimiterPosition === undefined || + this.suggestionDelimiterPosition >= this.textInputCursorStart + ) { + return clear(); + } + return this.textInputContent.substring(this.suggestionDelimiterPosition + 1, this.textInputCursorStart); + } + + /** + * Executes the given async function, only when the last function + * executed by this method terminates. If there is already a pending + * function it is replaced by the new one. This ensures the result of + * these function come in the same order as the call order, and it also + * allows to skip obsolete intermediate calls. + * + * @private + * @param {function} func + */ + async _executeOrQueueFunction(func) { + if (this._hasMentionRpcInProgress) { + this._nextMentionRpcFunction = func; + return; + } + this._hasMentionRpcInProgress = true; + this._nextMentionRpcFunction = undefined; + try { + await this.async(func); + } finally { + this._hasMentionRpcInProgress = false; + if (this._nextMentionRpcFunction) { + this._executeOrQueueFunction(this._nextMentionRpcFunction); + } + } + } + + /** + * @private + * @param {string} htmlString + * @returns {string} + */ + _generateEmojisOnHtml(htmlString) { + for (const emoji of emojis) { + for (const source of emoji.sources) { + const escapedSource = String(source).replace( + /([.*+?=^!:${}()|[\]/\\])/g, + '\\$1'); + const regexp = new RegExp( + '(\\s|^)(' + escapedSource + ')(?=\\s|$)', + 'g'); + htmlString = htmlString.replace(regexp, '$1' + emoji.unicode); + } + } + return htmlString; + } + + /** + * + * Generates the html link related to the mentioned partner + * + * @private + * @param {string} body + * @returns {string} + */ + _generateMentionsLinks(body) { + // List of mention data to insert in the body. + // Useful to do the final replace after parsing to avoid using the + // same tag twice if two different mentions have the same name. + const mentions = []; + for (const partner of this.mentionedPartners) { + const placeholder = `@-mention-partner-${partner.id}`; + const text = `@${owl.utils.escape(partner.name)}`; + mentions.push({ + class: 'o_mail_redirect', + id: partner.id, + model: 'res.partner', + placeholder, + text, + }); + body = body.replace(text, placeholder); + } + for (const channel of this.mentionedChannels) { + const placeholder = `#-mention-channel-${channel.id}`; + const text = `#${owl.utils.escape(channel.name)}`; + mentions.push({ + class: 'o_channel_redirect', + id: channel.id, + model: 'mail.channel', + placeholder, + text, + }); + body = body.replace(text, placeholder); + } + const baseHREF = this.env.session.url('/web'); + for (const mention of mentions) { + const href = `href='${baseHREF}#model=${mention.model}&id=${mention.id}'`; + const attClass = `class='${mention.class}'`; + const dataOeId = `data-oe-id='${mention.id}'`; + const dataOeModel = `data-oe-model='${mention.model}'`; + const target = `target='_blank'`; + const link = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target}>${mention.text}</a>`; + body = body.replace(mention.placeholder, link); + } + return body; + } + + /** + * @private + * @param {string} content html content + * @returns {mail.channel_command|undefined} command, if any in the content + */ + _getCommandFromText(content) { + if (content.startsWith('/')) { + const firstWord = content.substring(1).split(/\s/)[0]; + return this.env.messaging.commands.find(command => { + if (command.name !== firstWord) { + return false; + } + if (command.channel_types) { + return command.channel_types.includes(this.thread.channel_type); + } + return true; + }); + } + return undefined; + } + + /** + * Updates the suggestion state based on the currently saved composer + * state (in particular content and cursor position). + * + * @private + */ + _onChangeUpdateSuggestionList() { + // Update the suggestion list immediately for a reactive UX... + this._updateSuggestionList(); + // ...and then update it again after the server returned data. + this._executeOrQueueFunction(async () => { + if ( + this.suggestionDelimiterPosition === undefined || + this.suggestionSearchTerm === undefined || + !this.suggestionModelName + ) { + // ignore obsolete call + return; + } + const Model = this.env.models[this.suggestionModelName]; + const searchTerm = this.suggestionSearchTerm; + await this.async(() => Model.fetchSuggestions(searchTerm, { thread: this.thread })); + this._updateSuggestionList(); + if ( + this.suggestionSearchTerm && + this.suggestionSearchTerm === searchTerm && + this.suggestionModelName && + this.env.models[this.suggestionModelName] === Model && + !this.hasSuggestions + ) { + this.closeSuggestions(); + } + }); + } + + /** + * @private + */ + _reset() { + this.update({ + attachments: [['unlink-all']], + isLastStateChangeProgrammatic: true, + mentionedChannels: [['unlink-all']], + mentionedPartners: [['unlink-all']], + subjectContent: "", + textInputContent: '', + textInputCursorEnd: 0, + textInputCursorStart: 0, + }); + } + + /** + * Updates the current suggestion list. This method should be called + * whenever the UI has to be refreshed following change in state. + * + * This method should ideally be a compute, but its dependencies are + * currently too complex to express due to accessing plenty of fields + * from all records of dynamic models. + * + * @private + */ + _updateSuggestionList() { + if ( + this.suggestionDelimiterPosition === undefined || + this.suggestionSearchTerm === undefined || + !this.suggestionModelName + ) { + return; + } + const Model = this.env.models[this.suggestionModelName]; + const [ + mainSuggestedRecords, + extraSuggestedRecords = [], + ] = Model.searchSuggestions(this.suggestionSearchTerm, { thread: this.thread }); + const sortFunction = Model.getSuggestionSortFunction(this.suggestionSearchTerm, { thread: this.thread }); + mainSuggestedRecords.sort(sortFunction); + extraSuggestedRecords.sort(sortFunction); + // arbitrary limit to avoid displaying too many elements at once + // ideally a load more mechanism should be introduced + const limit = 8; + mainSuggestedRecords.length = Math.min(mainSuggestedRecords.length, limit); + extraSuggestedRecords.length = Math.min(extraSuggestedRecords.length, limit - mainSuggestedRecords.length); + this.update({ + extraSuggestedRecords: [['replace', extraSuggestedRecords]], + hasToScrollToActiveSuggestion: true, + mainSuggestedRecords: [['replace', mainSuggestedRecords]], + }); + } + + /** + * Validates user's current typing as a correct mention keyword in order + * to trigger mentions suggestions display. + * Returns the mention keyword without the suggestion delimiter if it + * has been validated and false if not. + * + * @deprecated + * @private + * @param {boolean} beginningOnly + * @returns {string|boolean} + */ + _validateMentionKeyword(beginningOnly) { + // use position before suggestion delimiter because there should be whitespaces + // or line feed/carriage return before the suggestion delimiter + const beforeSuggestionDelimiterPosition = this.suggestionDelimiterPosition - 1; + if (beginningOnly && beforeSuggestionDelimiterPosition > 0) { + return false; + } + let searchStr = this.textInputContent.substring( + beforeSuggestionDelimiterPosition, + this.textInputCursorStart + ); + // regex string start with suggestion delimiter or whitespace then suggestion delimiter + const pattern = "^" + this.suggestionDelimiter + "|^\\s" + this.suggestionDelimiter; + const regexStart = new RegExp(pattern, 'g'); + // trim any left whitespaces or the left line feed/ carriage return + // at the beginning of the string + searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, ''); + if (regexStart.test(searchStr) && searchStr.length) { + searchStr = searchStr.replace(pattern, ''); + return !searchStr.includes(' ') && !/[\r\n]/.test(searchStr) + ? searchStr.replace(this.suggestionDelimiter, '') + : false; + } + return false; + } + } + + Composer.fields = { + /** + * Deprecated. Use `activeSuggestedRecord` instead. + */ + activeSuggestedCannedResponse: many2one('mail.canned_response', { + compute: '_computeActiveSuggestedCannedResponse', + dependencies: [ + 'activeSuggestedRecord', + 'suggestionDelimiter', + ], + }), + /** + * Deprecated. Use `activeSuggestedRecord` instead. + */ + activeSuggestedChannel: many2one('mail.thread', { + compute: '_computeActiveSuggestedChannel', + dependencies: [ + 'activeSuggestedRecord', + 'suggestionDelimiter', + ], + }), + /** + * Deprecated. Use `activeSuggestedRecord` instead. + */ + activeSuggestedChannelCommand: many2one('mail.channel_command', { + compute: '_computeActiveSuggestedChannelCommand', + dependencies: [ + 'activeSuggestedRecord', + 'suggestionDelimiter', + ], + }), + /** + * Deprecated. Use `activeSuggestedRecord` instead. + */ + activeSuggestedPartner: many2one('mail.partner', { + compute: '_computeActiveSuggestedPartner', + dependencies: [ + 'activeSuggestedRecord', + 'suggestionDelimiter', + ], + }), + /** + * Determines the suggested record that is currently active. This record + * is highlighted in the UI and it will be the selected record if the + * suggestion is confirmed by the user. + */ + activeSuggestedRecord: many2one('mail.model', { + compute: '_computeActiveSuggestedRecord', + dependencies: [ + 'activeSuggestedRecord', + 'extraSuggestedRecords', + 'mainSuggestedRecords', + ], + }), + /** + * Deprecated, suggestions should be used in a manner that does not + * depend on their type. Use `activeSuggestedRecord` directly instead. + */ + activeSuggestedRecordName: attr({ + compute: '_computeActiveSuggestedRecordName', + dependencies: [ + 'suggestionDelimiter', + ], + }), + attachments: many2many('mail.attachment', { + inverse: 'composers', + }), + /** + * This field watches the uploading (= temporary) status of attachments + * linked to this composer. + * + * Useful to determine whether there are some attachments that are being + * uploaded. + */ + attachmentsAreTemporary: attr({ + related: 'attachments.isTemporary', + }), + canPostMessage: attr({ + compute: '_computeCanPostMessage', + dependencies: [ + 'attachments', + 'hasUploadingAttachment', + 'isPostingMessage', + 'textInputContent', + ], + default: false, + }), + /** + * Instance of discuss if this composer is used as the reply composer + * from Inbox. This field is computed from the inverse relation and + * should be considered read-only. + */ + discussAsReplying: one2one('mail.discuss', { + inverse: 'replyingToMessageOriginThreadComposer', + }), + /** + * Deprecated. Use `extraSuggestedRecords` instead. + */ + extraSuggestedPartners: many2many('mail.partner', { + compute: '_computeExtraSuggestedPartners', + dependencies: [ + 'extraSuggestedRecords', + 'suggestionDelimiter', + ], + }), + /** + * Determines the extra records that are currently suggested. + * Allows to have different model types of mentions through a dynamic + * process. 2 arbitrary lists can be provided and the second is defined + * as "extra". + */ + extraSuggestedRecords: many2many('mail.model', { + compute: '_computeExtraSuggestedRecords', + dependencies: [ + 'extraSuggestedRecords', + 'mainSuggestedRecords', + 'suggestionDelimiterPosition', + ], + }), + /** + * Deprecated. Use `extraSuggestedRecords` instead. + */ + extraSuggestedRecordsList: attr({ + compute: '_computeExtraSuggestedRecordsList', + dependencies: [ + 'extraSuggestedRecords', + ], + }), + /** + * Deprecated, suggestions should be used in a manner that does not + * depend on their type. Use `extraSuggestedRecords` directly instead. + */ + extraSuggestedRecordsListName: attr({ + compute: '_computeExtraSuggestedRecordsListName', + dependencies: [ + 'suggestionDelimiter', + ], + }), + /** + * This field determines whether some attachments linked to this + * composer are being uploaded. + */ + hasUploadingAttachment: attr({ + compute: '_computeHasUploadingAttachment', + dependencies: [ + 'attachments', + 'attachmentsAreTemporary', + ], + }), + hasFocus: attr({ + default: false, + }), + /** + * States whether there is any result currently found for the current + * suggestion delimiter and search term, if applicable. + */ + hasSuggestions: attr({ + compute: '_computeHasSuggestions', + dependencies: [ + 'extraSuggestedRecords', + 'mainSuggestedRecords', + ], + default: false, + }), + /** + * Determines whether the currently active suggestion should be scrolled + * into view. + */ + hasToScrollToActiveSuggestion: attr({ + default: false, + }), + /** + * Determines whether the last change (since the last render) was + * programmatic. Useful to avoid restoring the state when its change was + * from a user action, in particular to prevent the cursor from jumping + * to its previous position after the user clicked on the textarea while + * it didn't have the focus anymore. + */ + isLastStateChangeProgrammatic: attr({ + default: false, + }), + /** + * If true composer will log a note, else a comment will be posted. + */ + isLog: attr({ + default: false, + }), + /** + * Determines whether a post_message request is currently pending. + */ + isPostingMessage: attr(), + /** + * Deprecated. Use `mainSuggestedRecords` instead. + */ + mainSuggestedPartners: many2many('mail.partner', { + compute: '_computeMainSuggestedPartners', + dependencies: [ + 'mainSuggestedRecords', + 'suggestionDelimiter', + ], + }), + /** + * Determines the main records that are currently suggested. + * Allows to have different model types of mentions through a dynamic + * process. 2 arbitrary lists can be provided and the first is defined + * as "main". + */ + mainSuggestedRecords: many2many('mail.model', { + compute: '_computeMainSuggestedRecords', + dependencies: [ + 'mainSuggestedRecords', + 'suggestionDelimiterPosition', + ], + }), + /** + * Deprecated. Use `mainSuggestedRecords` instead. + */ + mainSuggestedRecordsList: attr({ + compute: '_computeMainSuggestedRecordsList', + dependencies: [ + 'mainSuggestedRecords', + ], + }), + /** + * Deprecated, suggestions should be used in a manner that does not + * depend on their type. Use `mainSuggestedRecords` directly instead. + */ + mainSuggestedRecordsListName: attr({ + compute: '_computeMainSuggestedRecordsListName', + dependencies: [ + 'suggestionDelimiter', + ], + }), + mentionedChannels: many2many('mail.thread', { + compute: '_computeMentionedChannels', + dependencies: ['textInputContent'], + }), + mentionedPartners: many2many('mail.partner', { + compute: '_computeMentionedPartners', + dependencies: [ + 'mentionedPartners', + 'mentionedPartnersName', + 'textInputContent', + ], + }), + /** + * Serves as compute dependency. + */ + mentionedPartnersName: attr({ + related: 'mentionedPartners.name', + }), + /** + * Not a real field, used to trigger `_onChangeUpdateSuggestionList` + * when one of the dependencies changes. + */ + onChangeUpdateSuggestionList: attr({ + compute: '_onChangeUpdateSuggestionList', + dependencies: [ + 'suggestionDelimiterPosition', + 'suggestionModelName', + 'suggestionSearchTerm', + 'thread', + ], + }), + /** + * Determines the extra `mail.partner` (on top of existing followers) + * that will receive the message being composed by `this`, and that will + * also be added as follower of `this.thread`. + */ + recipients: many2many('mail.partner', { + compute: '_computeRecipients', + dependencies: [ + 'isLog', + 'mentionedPartners', + 'threadSuggestedRecipientInfoListIsSelected', + // FIXME thread.suggestedRecipientInfoList.partner should be a + // dependency, but it is currently impossible to have a related + // m2o through a m2m. task-2261221 + ] + }), + /** + * Serves as compute dependency. + */ + threadSuggestedRecipientInfoList: many2many('mail.suggested_recipient_info', { + related: 'thread.suggestedRecipientInfoList', + }), + /** + * Serves as compute dependency. + */ + threadSuggestedRecipientInfoListIsSelected: attr({ + related: 'threadSuggestedRecipientInfoList.isSelected', + }), + /** + * Composer subject input content. + */ + subjectContent: attr({ + default: "", + }), + /** + * Deprecated. Use `mainSuggestedRecords` instead. + */ + suggestedCannedResponses: many2many('mail.canned_response', { + compute: '_computeSuggestedCannedResponses', + dependencies: [ + 'mainSuggestedRecords', + 'suggestionDelimiter', + ], + }), + /** + * Deprecated. Use `mainSuggestedRecords` instead. + */ + suggestedChannelCommands: many2many('mail.channel_command', { + compute: '_computeSuggestedChannelCommands', + dependencies: [ + 'mainSuggestedRecords', + 'suggestionDelimiter', + ], + }), + /** + * Deprecated. Use `mainSuggestedRecords` instead. + */ + suggestedChannels: many2many('mail.thread', { + compute: '_computeSuggestedChannels', + dependencies: [ + 'mainSuggestedRecords', + 'suggestionDelimiter', + ], + }), + /** + * States which type of suggestion is currently in progress, if any. + * The value of this field contains the magic char that corresponds to + * the suggestion currently in progress, and it must be one of these: + * canned responses (:), channels (#), commands (/) and partners (@) + */ + suggestionDelimiter: attr({ + compute: '_computeSuggestionDelimiter', + dependencies: [ + 'suggestionDelimiterPosition', + 'textInputContent', + ], + }), + /** + * States the position inside textInputContent of the suggestion + * delimiter currently in consideration. Useful if the delimiter char + * appears multiple times in the content. + * Note: the position is 0 based so it's important to compare to + * `undefined` when checking for the absence of a value. + */ + suggestionDelimiterPosition: attr({ + compute: '_computeSuggestionDelimiterPosition', + dependencies: [ + 'textInputContent', + 'textInputCursorEnd', + 'textInputCursorStart', + ], + }), + /** + * States the target model name of the suggestion currently in progress, + * if any. + */ + suggestionModelName: attr({ + compute: '_computeSuggestionModelName', + dependencies: [ + 'suggestionDelimiter', + ], + }), + /** + * States the search term to use for suggestions (if any). + */ + suggestionSearchTerm: attr({ + compute: '_computeSuggestionSearchTerm', + dependencies: [ + 'suggestionDelimiterPosition', + 'textInputContent', + 'textInputCursorStart', + ], + }), + textInputContent: attr({ + default: "", + }), + textInputCursorEnd: attr({ + default: 0, + }), + textInputCursorStart: attr({ + default: 0, + }), + textInputSelectionDirection: attr({ + default: "none", + }), + thread: one2one('mail.thread', { + inverse: 'composer', + }), + }; + + Composer.modelName = 'mail.composer'; + + return Composer; +} + +registerNewModel('mail.composer', factory); + +}); diff --git a/addons/mail/static/src/models/country/country.js b/addons/mail/static/src/models/country/country.js new file mode 100644 index 00000000..fb3617cf --- /dev/null +++ b/addons/mail/static/src/models/country/country.js @@ -0,0 +1,55 @@ +odoo.define('mail/static/src/models/country/country.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); + +function factory(dependencies) { + + class Country extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @private + * @returns {string|undefined} + */ + _computeFlagUrl() { + if (!this.code) { + return clear(); + } + return `/base/static/img/country_flags/${this.code}.png`; + } + + } + + Country.fields = { + code: attr(), + flagUrl: attr({ + compute: '_computeFlagUrl', + dependencies: [ + 'code', + ], + }), + id: attr(), + name: attr(), + }; + + Country.modelName = 'mail.country'; + + return Country; +} + +registerNewModel('mail.country', factory); + +}); diff --git a/addons/mail/static/src/models/device/device.js b/addons/mail/static/src/models/device/device.js new file mode 100644 index 00000000..29e664d3 --- /dev/null +++ b/addons/mail/static/src/models/device/device.js @@ -0,0 +1,71 @@ +odoo.define('mail/static/src/models/device/device.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Device extends dependencies['mail.model'] { + + /** + * @override + */ + _created() { + const res = super._created(...arguments); + this._refresh(); + this._onResize = _.debounce(() => this._refresh(), 100); + return res; + } + + /** + * @override + */ + _willDelete() { + window.removeEventListener('resize', this._onResize); + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Called when messaging is started. + */ + start() { + // TODO FIXME Not using this.env.browser because it's proxified, and + // addEventListener does not work on proxified window. task-2234596 + window.addEventListener('resize', this._onResize); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + */ + _refresh() { + this.update({ + globalWindowInnerHeight: this.env.browser.innerHeight, + globalWindowInnerWidth: this.env.browser.innerWidth, + isMobile: this.env.device.isMobile, + }); + } + } + + Device.fields = { + globalWindowInnerHeight: attr(), + globalWindowInnerWidth: attr(), + isMobile: attr(), + }; + + Device.modelName = 'mail.device'; + + return Device; +} + +registerNewModel('mail.device', factory); + +}); diff --git a/addons/mail/static/src/models/dialog/dialog.js b/addons/mail/static/src/models/dialog/dialog.js new file mode 100644 index 00000000..018951fe --- /dev/null +++ b/addons/mail/static/src/models/dialog/dialog.js @@ -0,0 +1,32 @@ +odoo.define('mail/static/src/models/dialog/dialog.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { many2one, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Dialog extends dependencies['mail.model'] {} + + Dialog.fields = { + manager: many2one('mail.dialog_manager', { + inverse: 'dialogs', + }), + /** + * Content of dialog that is directly linked to a record that models + * a UI component, such as AttachmentViewer. These records must be + * created from @see `mail.dialog_manager:open()`. + */ + record: one2one('mail.model', { + isCausal: true, + }), + }; + + Dialog.modelName = 'mail.dialog'; + + return Dialog; +} + +registerNewModel('mail.dialog', factory); + +}); diff --git a/addons/mail/static/src/models/dialog_manager/dialog_manager.js b/addons/mail/static/src/models/dialog_manager/dialog_manager.js new file mode 100644 index 00000000..4d86e340 --- /dev/null +++ b/addons/mail/static/src/models/dialog_manager/dialog_manager.js @@ -0,0 +1,52 @@ +odoo.define('mail/static/src/models/dialog_manager/dialog_manager.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class DialogManager extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @param {string} modelName + * @param {Object} [recordData] + */ + open(modelName, recordData) { + if (!modelName) { + throw new Error("Dialog should have a link to a model"); + } + const Model = this.env.models[modelName]; + if (!Model) { + throw new Error(`No model exists with name ${modelName}`); + } + const record = Model.create(recordData); + const dialog = this.env.models['mail.dialog'].create({ + manager: [['link', this]], + record: [['link', record]], + }); + return dialog; + } + + } + + DialogManager.fields = { + // FIXME: dependent on implementation that uses insert order in relations!! + dialogs: one2many('mail.dialog', { + inverse: 'manager', + isCausal: true, + }), + }; + + DialogManager.modelName = 'mail.dialog_manager'; + + return DialogManager; +} + +registerNewModel('mail.dialog_manager', factory); + +}); diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js new file mode 100644 index 00000000..513b77fd --- /dev/null +++ b/addons/mail/static/src/models/discuss/discuss.js @@ -0,0 +1,568 @@ +odoo.define('mail/static/src/models/discuss.discuss.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); + +function factory(dependencies) { + + class Discuss extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @param {mail.thread} thread + */ + cancelThreadRenaming(thread) { + this.update({ renamingThreads: [['unlink', thread]] }); + } + + clearIsAddingItem() { + this.update({ + addingChannelValue: "", + isAddingChannel: false, + isAddingChat: false, + }); + } + + clearReplyingToMessage() { + this.update({ replyingToMessage: [['unlink-all']] }); + } + + /** + * Close the discuss app. Should reset its internal state. + */ + close() { + this.update({ isOpen: false }); + } + + focus() { + this.update({ isDoFocus: true }); + } + + /** + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + async handleAddChannelAutocompleteSelect(ev, ui) { + const name = this.addingChannelValue; + this.clearIsAddingItem(); + if (ui.item.special) { + const channel = await this.async(() => + this.env.models['mail.thread'].performRpcCreateChannel({ + name, + privacy: ui.item.special, + }) + ); + channel.open(); + } else { + const channel = await this.async(() => + this.env.models['mail.thread'].performRpcJoinChannel({ + channelId: ui.item.id, + }) + ); + channel.open(); + } + } + + /** + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + async handleAddChannelAutocompleteSource(req, res) { + const value = req.term; + const escapedValue = owl.utils.escape(value); + this.update({ addingChannelValue: value }); + const domain = [ + ['channel_type', '=', 'channel'], + ['name', 'ilike', value], + ]; + const fields = ['channel_type', 'name', 'public', 'uuid']; + const result = await this.async(() => this.env.services.rpc({ + model: "mail.channel", + method: "search_read", + kwargs: { + domain, + fields, + }, + })); + const items = result.map(data => { + let escapedName = owl.utils.escape(data.name); + return Object.assign(data, { + label: escapedName, + value: escapedName + }); + }); + // XDU FIXME could use a component but be careful with owl's + // renderToString https://github.com/odoo/owl/issues/708 + items.push({ + label: _.str.sprintf( + `<strong>${this.env._t('Create %s')}</strong>`, + `<em><span class="fa fa-hashtag"/>${escapedValue}</em>`, + ), + escapedValue, + special: 'public' + }, { + label: _.str.sprintf( + `<strong>${this.env._t('Create %s')}</strong>`, + `<em><span class="fa fa-lock"/>${escapedValue}</em>`, + ), + escapedValue, + special: 'private' + }); + res(items); + } + + /** + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + handleAddChatAutocompleteSelect(ev, ui) { + this.env.messaging.openChat({ partnerId: ui.item.id }); + this.clearIsAddingItem(); + } + + /** + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + handleAddChatAutocompleteSource(req, res) { + const value = owl.utils.escape(req.term); + this.env.models['mail.partner'].imSearch({ + callback: partners => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: value, + limit: 10, + }); + } + + /** + * Open thread from init active id. `initActiveId` is used to refer to + * a thread that we may not have full data yet, such as when messaging + * is not yet initialized. + */ + openInitThread() { + const [model, id] = typeof this.initActiveId === 'number' + ? ['mail.channel', this.initActiveId] + : this.initActiveId.split('_'); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: model !== 'mail.box' ? Number(id) : id, + model, + }); + if (!thread) { + return; + } + thread.open(); + if (this.env.messaging.device.isMobile && thread.channel_type) { + this.update({ activeMobileNavbarTabId: thread.channel_type }); + } + } + + + /** + * Opens the given thread in Discuss, and opens Discuss if necessary. + * + * @param {mail.thread} thread + */ + async openThread(thread) { + this.update({ + thread: [['link', thread]], + }); + this.focus(); + if (!this.isOpen) { + this.env.bus.trigger('do-action', { + action: 'mail.action_discuss', + options: { + active_id: this.threadToActiveId(this), + clear_breadcrumbs: false, + on_reverse_breadcrumb: () => this.close(), + }, + }); + } + } + + /** + * @param {mail.thread} thread + * @param {string} newName + */ + async renameThread(thread, newName) { + await this.async(() => thread.rename(newName)); + this.update({ renamingThreads: [['unlink', thread]] }); + } + + /** + * Action to initiate reply to given message in Inbox. Assumes that + * Discuss and Inbox are already opened. + * + * @param {mail.message} message + */ + replyToMessage(message) { + this.update({ replyingToMessage: [['link', message]] }); + // avoid to reply to a note by a message and vice-versa. + // subject to change later by allowing subtype choice. + this.replyingToMessageOriginThreadComposer.update({ + isLog: !message.is_discussion && !message.is_notification + }); + this.focus(); + } + + /** + * @param {mail.thread} thread + */ + setThreadRenaming(thread) { + this.update({ renamingThreads: [['link', thread]] }); + } + + /** + * @param {mail.thread} thread + * @returns {string} + */ + threadToActiveId(thread) { + return `${thread.model}_${thread.id}`; + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string|undefined} + */ + _computeActiveId() { + if (!this.thread) { + return clear(); + } + return this.threadToActiveId(this.thread); + } + + /** + * @private + * @returns {string} + */ + _computeAddingChannelValue() { + if (!this.isOpen) { + return ""; + } + return this.addingChannelValue; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasThreadView() { + if (!this.thread || !this.isOpen) { + return false; + } + if ( + this.env.messaging.device.isMobile && + ( + this.activeMobileNavbarTabId !== 'mailbox' || + this.thread.model !== 'mail.box' + ) + ) { + return false; + } + return true; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsAddingChannel() { + if (!this.isOpen) { + return false; + } + return this.isAddingChannel; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsAddingChat() { + if (!this.isOpen) { + return false; + } + return this.isAddingChat; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsReplyingToMessage() { + return !!this.replyingToMessage; + } + + /** + * Ensures the reply feature is disabled if discuss is not open. + * + * @private + * @returns {mail.message|undefined} + */ + _computeReplyingToMessage() { + if (!this.isOpen) { + return [['unlink-all']]; + } + return []; + } + + + /** + * Only pinned threads are allowed in discuss. + * + * @private + * @returns {mail.thread|undefined} + */ + _computeThread() { + let thread = this.thread; + if (this.env.messaging && + this.env.messaging.inbox && + this.env.messaging.device.isMobile && + this.activeMobileNavbarTabId === 'mailbox' && + this.initActiveId !== 'mail.box_inbox' && + !thread + ) { + // After loading Discuss from an arbitrary tab other then 'mailbox', + // switching to 'mailbox' requires to also set its inner-tab ; + // by default the 'inbox'. + return [['replace', this.env.messaging.inbox]]; + } + if (!thread || !thread.isPinned) { + return [['unlink']]; + } + return []; + } + + } + + Discuss.fields = { + activeId: attr({ + compute: '_computeActiveId', + dependencies: [ + 'thread', + 'threadId', + 'threadModel', + ], + }), + /** + * Active mobile navbar tab, either 'mailbox', 'chat', or 'channel'. + */ + activeMobileNavbarTabId: attr({ + default: 'mailbox', + }), + /** + * Value that is used to create a channel from the sidebar. + */ + addingChannelValue: attr({ + compute: '_computeAddingChannelValue', + default: "", + dependencies: ['isOpen'], + }), + /** + * Serves as compute dependency. + */ + device: one2one('mail.device', { + related: 'messaging.device', + }), + /** + * Serves as compute dependency. + */ + deviceIsMobile: attr({ + related: 'device.isMobile', + }), + /** + * Determine if the moderation discard dialog is displayed. + */ + hasModerationDiscardDialog: attr({ + default: false, + }), + /** + * Determine if the moderation reject dialog is displayed. + */ + hasModerationRejectDialog: attr({ + default: false, + }), + /** + * Determines whether `this.thread` should be displayed. + */ + hasThreadView: attr({ + compute: '_computeHasThreadView', + dependencies: [ + 'activeMobileNavbarTabId', + 'deviceIsMobile', + 'isOpen', + 'thread', + 'threadModel', + ], + }), + /** + * Formatted init thread on opening discuss for the first time, + * when no active thread is defined. Useful to set a thread to + * open without knowing its local id in advance. + * Support two formats: + * {string} <threadModel>_<threadId> + * {int} <channelId> with default model of 'mail.channel' + */ + initActiveId: attr({ + default: 'mail.box_inbox', + }), + /** + * Determine whether current user is currently adding a channel from + * the sidebar. + */ + isAddingChannel: attr({ + compute: '_computeIsAddingChannel', + default: false, + dependencies: ['isOpen'], + }), + /** + * Determine whether current user is currently adding a chat from + * the sidebar. + */ + isAddingChat: attr({ + compute: '_computeIsAddingChat', + default: false, + dependencies: ['isOpen'], + }), + /** + * Determine whether this discuss should be focused at next render. + */ + isDoFocus: attr({ + default: false, + }), + /** + * Whether the discuss app is open or not. Useful to determine + * whether the discuss or chat window logic should be applied. + */ + isOpen: attr({ + default: false, + }), + isReplyingToMessage: attr({ + compute: '_computeIsReplyingToMessage', + default: false, + dependencies: ['replyingToMessage'], + }), + isThreadPinned: attr({ + related: 'thread.isPinned', + }), + /** + * The menu_id of discuss app, received on mail/init_messaging and + * used to open discuss from elsewhere. + */ + menu_id: attr({ + default: null, + }), + messaging: one2one('mail.messaging', { + inverse: 'discuss', + }), + messagingInbox: many2one('mail.thread', { + related: 'messaging.inbox', + }), + renamingThreads: one2many('mail.thread'), + /** + * The message that is currently selected as being replied to in Inbox. + * There is only one reply composer shown at a time, which depends on + * this selected message. + */ + replyingToMessage: many2one('mail.message', { + compute: '_computeReplyingToMessage', + dependencies: [ + 'isOpen', + 'replyingToMessage', + ], + }), + /** + * The thread concerned by the reply feature in Inbox. It depends on the + * message set to be replied, and should be considered read-only. + */ + replyingToMessageOriginThread: many2one('mail.thread', { + related: 'replyingToMessage.originThread', + }), + /** + * The composer to display for the reply feature in Inbox. It depends + * on the message set to be replied, and should be considered read-only. + */ + replyingToMessageOriginThreadComposer: one2one('mail.composer', { + inverse: 'discussAsReplying', + related: 'replyingToMessageOriginThread.composer', + }), + /** + * Quick search input value in the discuss sidebar (desktop). Useful + * to filter channels and chats based on this input content. + */ + sidebarQuickSearchValue: attr({ + default: "", + }), + /** + * Determines the domain to apply when fetching messages for `this.thread`. + * This value should only be written by the control panel. + */ + stringifiedDomain: attr({ + default: '[]', + }), + /** + * Determines the `mail.thread` that should be displayed by `this`. + */ + thread: many2one('mail.thread', { + compute: '_computeThread', + dependencies: [ + 'activeMobileNavbarTabId', + 'deviceIsMobile', + 'isThreadPinned', + 'messaging', + 'messagingInbox', + 'thread', + 'threadModel', + ], + }), + threadId: attr({ + related: 'thread.id', + }), + threadModel: attr({ + related: 'thread.model', + }), + /** + * States the `mail.thread_view` displaying `this.thread`. + */ + threadView: one2one('mail.thread_view', { + related: 'threadViewer.threadView', + }), + /** + * Determines the `mail.thread_viewer` managing the display of `this.thread`. + */ + threadViewer: one2one('mail.thread_viewer', { + default: [['create']], + inverse: 'discuss', + isCausal: true, + }), + }; + + Discuss.modelName = 'mail.discuss'; + + return Discuss; +} + +registerNewModel('mail.discuss', factory); + +}); diff --git a/addons/mail/static/src/models/follower/follower.js b/addons/mail/static/src/models/follower/follower.js new file mode 100644 index 00000000..493fe836 --- /dev/null +++ b/addons/mail/static/src/models/follower/follower.js @@ -0,0 +1,293 @@ +odoo.define('mail/static/src/models/follower.follower.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Follower extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @returns {Object} + */ + static convertData(data) { + const data2 = {}; + if ('channel_id' in data) { + if (!data.channel_id) { + data2.channel = [['unlink-all']]; + } else { + const channelData = { + id: data.channel_id, + model: 'mail.channel', + name: data.name, + }; + data2.channel = [['insert', channelData]]; + } + } + if ('id' in data) { + data2.id = data.id; + } + if ('is_active' in data) { + data2.isActive = data.is_active; + } + if ('is_editable' in data) { + data2.isEditable = data.is_editable; + } + if ('partner_id' in data) { + if (!data.partner_id) { + data2.partner = [['unlink-all']]; + } else { + const partnerData = { + display_name: data.display_name, + email: data.email, + id: data.partner_id, + name: data.name, + }; + data2.partner = [['insert', partnerData]]; + } + } + return data2; + } + + /** + * Close subtypes dialog + */ + closeSubtypes() { + this._subtypesListDialog.delete(); + this._subtypesListDialog = undefined; + } + + /** + * Opens the most appropriate view that is a profile for this follower. + */ + async openProfile() { + if (this.partner) { + return this.partner.openProfile(); + } + return this.channel.openProfile(); + } + + /** + * Remove this follower from its related thread. + */ + async remove() { + const partner_ids = []; + const channel_ids = []; + if (this.partner) { + partner_ids.push(this.partner.id); + } else { + channel_ids.push(this.channel.id); + } + await this.async(() => this.env.services.rpc({ + model: this.followedThread.model, + method: 'message_unsubscribe', + args: [[this.followedThread.id], partner_ids, channel_ids] + })); + const followedThread = this.followedThread; + this.delete(); + followedThread.fetchAndUpdateSuggestedRecipients(); + } + + /** + * @param {mail.follower_subtype} subtype + */ + selectSubtype(subtype) { + if (!this.selectedSubtypes.includes(subtype)) { + this.update({ selectedSubtypes: [['link', subtype]] }); + } + } + + /** + * Show (editable) list of subtypes of this follower. + */ + async showSubtypes() { + const subtypesData = await this.async(() => this.env.services.rpc({ + route: '/mail/read_subscription_data', + params: { follower_id: this.id }, + })); + this.update({ subtypes: [['unlink-all']] }); + for (const data of subtypesData) { + const subtype = this.env.models['mail.follower_subtype'].insert( + this.env.models['mail.follower_subtype'].convertData(data) + ); + this.update({ subtypes: [['link', subtype]] }); + if (data.followed) { + this.update({ selectedSubtypes: [['link', subtype]] }); + } else { + this.update({ selectedSubtypes: [['unlink', subtype]] }); + } + } + this._subtypesListDialog = this.env.messaging.dialogManager.open('mail.follower_subtype_list', { + follower: [['link', this]], + }); + } + + /** + * @param {mail.follower_subtype} subtype + */ + unselectSubtype(subtype) { + if (this.selectedSubtypes.includes(subtype)) { + this.update({ selectedSubtypes: [['unlink', subtype]] }); + } + } + + /** + * Update server-side subscription of subtypes of this follower. + */ + async updateSubtypes() { + if (this.selectedSubtypes.length === 0) { + this.remove(); + } else { + const kwargs = { + subtype_ids: this.selectedSubtypes.map(subtype => subtype.id), + }; + if (this.partner) { + kwargs.partner_ids = [this.partner.id]; + } else { + kwargs.channel_ids = [this.channel.id]; + } + await this.async(() => this.env.services.rpc({ + model: this.followedThread.model, + method: 'message_subscribe', + args: [[this.followedThread.id]], + kwargs, + })); + this.env.services['notification'].notify({ + type: 'success', + message: this.env._t("The subscription preferences were successfully applied."), + }); + } + this.closeSubtypes(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @private + * @returns {string} + */ + _computeName() { + if (this.channel) { + return this.channel.name; + } + if (this.partner) { + return this.partner.name; + } + return ''; + } + + /** + * @private + * @returns {integer} + */ + _computeResId() { + if (this.partner) { + return this.partner.id; + } + if (this.channel) { + return this.channel.id; + } + return 0; + } + + /** + * @private + * @returns {string} + */ + _computeResModel() { + if (this.partner) { + return this.partner.model; + } + if (this.channel) { + return this.channel.model; + } + return ''; + } + + } + + Follower.fields = { + resId: attr({ + compute: '_computeResId', + default: 0, + dependencies: [ + 'channelId', + 'partnerId', + ], + }), + channel: many2one('mail.thread'), + channelId: attr({ + related: 'channel.id', + }), + channelModel: attr({ + related: 'channel.model', + }), + channelName: attr({ + related: 'channel.name', + }), + displayName: attr({ + related: 'partner.display_name' + }), + followedThread: many2one('mail.thread', { + inverse: 'followers', + }), + id: attr(), + isActive: attr({ + default: true, + }), + isEditable: attr({ + default: false, + }), + name: attr({ + compute: '_computeName', + dependencies: [ + 'channelName', + 'partnerName', + ], + }), + partner: many2one('mail.partner'), + partnerId: attr({ + related: 'partner.id', + }), + partnerModel: attr({ + related: 'partner.model', + }), + partnerName: attr({ + related: 'partner.name', + }), + resModel: attr({ + compute: '_computeResModel', + default: '', + dependencies: [ + 'channelModel', + 'partnerModel', + ], + }), + selectedSubtypes: many2many('mail.follower_subtype'), + subtypes: many2many('mail.follower_subtype'), + }; + + Follower.modelName = 'mail.follower'; + + return Follower; +} + +registerNewModel('mail.follower', factory); + +}); diff --git a/addons/mail/static/src/models/follower_subtype/follower_subtype.js b/addons/mail/static/src/models/follower_subtype/follower_subtype.js new file mode 100644 index 00000000..68c16fce --- /dev/null +++ b/addons/mail/static/src/models/follower_subtype/follower_subtype.js @@ -0,0 +1,82 @@ +odoo.define('mail/static/src/models/follower_subtype/follower_subtype.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class FollowerSubtype extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @returns {Object} + */ + static convertData(data) { + const data2 = {}; + if ('default' in data) { + data2.isDefault = data.default; + } + if ('id' in data) { + data2.id = data.id; + } + if ('internal' in data) { + data2.isInternal = data.internal; + } + if ('name' in data) { + data2.name = data.name; + } + if ('parent_model' in data) { + data2.parentModel = data.parent_model; + } + if ('res_model' in data) { + data2.resModel = data.res_model; + } + if ('sequence' in data) { + data2.sequence = data.sequence; + } + return data2; + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + } + + FollowerSubtype.fields = { + id: attr(), + isDefault: attr({ + default: false, + }), + isInternal: attr({ + default: false, + }), + name: attr(), + // AKU FIXME: use relation instead + parentModel: attr(), + // AKU FIXME: use relation instead + resModel: attr(), + sequence: attr(), + }; + + FollowerSubtype.modelName = 'mail.follower_subtype'; + + return FollowerSubtype; +} + +registerNewModel('mail.follower_subtype', factory); + +}); diff --git a/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js new file mode 100644 index 00000000..9d67cedb --- /dev/null +++ b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js @@ -0,0 +1,22 @@ +odoo.define('mail/static/src/models/follower_subtype_list/follower_subtype_list.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class FollowerSubtypeList extends dependencies['mail.model'] {} + + FollowerSubtypeList.fields = { + follower: many2one('mail.follower'), + }; + + FollowerSubtypeList.modelName = 'mail.follower_subtype_list'; + + return FollowerSubtypeList; +} + +registerNewModel('mail.follower_subtype_list', factory); + +}); diff --git a/addons/mail/static/src/models/locale/locale.js b/addons/mail/static/src/models/locale/locale.js new file mode 100644 index 00000000..50167f07 --- /dev/null +++ b/addons/mail/static/src/models/locale/locale.js @@ -0,0 +1,52 @@ +odoo.define('mail/static/src/models/locale/locale.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Locale extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string} + */ + _computeLanguage() { + return this.env._t.database.parameters.code; + } + + /** + * @private + * @returns {string} + */ + _computeTextDirection() { + return this.env._t.database.parameters.direction; + } + + } + + Locale.fields = { + /** + * Language used by interface, formatted like {language ISO 2}_{country ISO 2} (eg: fr_FR). + */ + language: attr({ + compute: '_computeLanguage', + }), + textDirection: attr({ + compute: '_computeTextDirection', + }), + }; + + Locale.modelName = 'mail.locale'; + + return Locale; +} + +registerNewModel('mail.locale', factory); + +}); diff --git a/addons/mail/static/src/models/mail_template/mail_template.js b/addons/mail/static/src/models/mail_template/mail_template.js new file mode 100644 index 00000000..3144c314 --- /dev/null +++ b/addons/mail/static/src/models/mail_template/mail_template.js @@ -0,0 +1,83 @@ +odoo.define('mail/static/src/models/mail_template/mail_template.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class MailTemplate extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @param {mail.activity} activity + */ + preview(activity) { + const action = { + name: this.env._t("Compose Email"), + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: activity.thread.id, + default_model: activity.thread.model, + default_use_template: true, + default_template_id: this.id, + force_email: true, + }, + }; + this.env.bus.trigger('do-action', { + action, + options: { + on_close: () => { + activity.thread.refresh(); + }, + }, + }); + } + + /** + * @param {mail.activity} activity + */ + async send(activity) { + await this.async(() => this.env.services.rpc({ + model: activity.thread.model, + method: 'activity_send_mail', + args: [[activity.thread.id], this.id], + })); + activity.thread.refresh(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + } + + MailTemplate.fields = { + activities: many2many('mail.activity', { + inverse: 'mailTemplates', + }), + id: attr(), + name: attr(), + }; + + MailTemplate.modelName = 'mail.mail_template'; + + return MailTemplate; +} + +registerNewModel('mail.mail_template', factory); + +}); diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js new file mode 100644 index 00000000..f5c45bfa --- /dev/null +++ b/addons/mail/static/src/models/message/message.js @@ -0,0 +1,817 @@ +odoo.define('mail/static/src/models/message/message.js', function (require) { +'use strict'; + +const emojis = require('mail.emojis'); +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); +const { addLink, htmlToTextContentInline, parseAndTransform, timeFromNow } = require('mail.utils'); + +const { str_to_datetime } = require('web.time'); + +function factory(dependencies) { + + class Message extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + static checkAll(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + threadCache.update({ checkedMessages: [['link', threadCache.messages]] }); + } + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('attachment_ids' in data) { + if (!data.attachment_ids) { + data2.attachments = [['unlink-all']]; + } else { + data2.attachments = [ + ['insert-and-replace', data.attachment_ids.map(attachmentData => + this.env.models['mail.attachment'].convertData(attachmentData) + )], + ]; + } + } + if ('author_id' in data) { + if (!data.author_id) { + data2.author = [['unlink-all']]; + } else if (data.author_id[0] !== 0) { + // partner id 0 is a hack of message_format to refer to an + // author non-related to a partner. display_name equals + // email_from, so this is omitted due to being redundant. + data2.author = [ + ['insert', { + display_name: data.author_id[1], + id: data.author_id[0], + }], + ]; + } + } + if ('body' in data) { + data2.body = data.body; + } + if ('channel_ids' in data && data.channel_ids) { + const channels = data.channel_ids + .map(channelId => + this.env.models['mail.thread'].findFromIdentifyingData({ + id: channelId, + model: 'mail.channel', + }) + ).filter(channel => !!channel); + data2.serverChannels = [['replace', channels]]; + } + if ('date' in data && data.date) { + data2.date = moment(str_to_datetime(data.date)); + } + if ('email_from' in data) { + data2.email_from = data.email_from; + } + if ('history_partner_ids' in data) { + data2.isHistory = data.history_partner_ids.includes(this.env.messaging.currentPartner.id); + } + if ('id' in data) { + data2.id = data.id; + } + if ('is_discussion' in data) { + data2.is_discussion = data.is_discussion; + } + if ('is_note' in data) { + data2.is_note = data.is_note; + } + if ('is_notification' in data) { + data2.is_notification = data.is_notification; + } + if ('message_type' in data) { + data2.message_type = data.message_type; + } + if ('model' in data && 'res_id' in data && data.model && data.res_id) { + const originThreadData = { + id: data.res_id, + model: data.model, + }; + if ('record_name' in data && data.record_name) { + originThreadData.name = data.record_name; + } + if ('res_model_name' in data && data.res_model_name) { + originThreadData.model_name = data.res_model_name; + } + if ('module_icon' in data) { + originThreadData.moduleIcon = data.module_icon; + } + data2.originThread = [['insert', originThreadData]]; + } + if ('moderation_status' in data) { + data2.moderation_status = data.moderation_status; + } + if ('needaction_partner_ids' in data) { + data2.isNeedaction = data.needaction_partner_ids.includes(this.env.messaging.currentPartner.id); + } + if ('notifications' in data) { + data2.notifications = [['insert', data.notifications.map(notificationData => + this.env.models['mail.notification'].convertData(notificationData) + )]]; + } + if ('starred_partner_ids' in data) { + data2.isStarred = data.starred_partner_ids.includes(this.env.messaging.currentPartner.id); + } + if ('subject' in data) { + data2.subject = data.subject; + } + if ('subtype_description' in data) { + data2.subtype_description = data.subtype_description; + } + if ('subtype_id' in data) { + data2.subtype_id = data.subtype_id; + } + if ('tracking_value_ids' in data) { + data2.tracking_value_ids = data.tracking_value_ids; + } + + return data2; + } + + /** + * Mark all messages of current user with given domain as read. + * + * @static + * @param {Array[]} domain + */ + static async markAllAsRead(domain) { + await this.env.services.rpc({ + model: 'mail.message', + method: 'mark_all_as_read', + kwargs: { domain }, + }); + } + + /** + * Mark provided messages as read. Messages that have been marked as + * read are acknowledged by server with response as longpolling + * notification of following format: + * + * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }] + * + * @see mail.messaging_notification_handler:_handleNotificationPartnerMarkAsRead() + * + * @static + * @param {mail.message[]} messages + */ + static async markAsRead(messages) { + await this.env.services.rpc({ + model: 'mail.message', + method: 'set_message_done', + args: [messages.map(message => message.id)] + }); + } + + /** + * Applies the moderation `decision` on the provided messages. + * + * @static + * @param {mail.message[]} messages + * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject' + * @param {Object|undefined} [kwargs] optional data to pass on + * message moderation. This is provided when rejecting the messages + * for which title and comment give reason(s) for reject. + * @param {string} [kwargs.title] + * @param {string} [kwargs.comment] + */ + static async moderate(messages, decision, kwargs) { + const messageIds = messages.map(message => message.id); + await this.env.services.rpc({ + model: 'mail.message', + method: 'moderate', + args: [messageIds, decision], + kwargs: kwargs, + }); + } + /** + * Performs the `message_fetch` RPC on `mail.message`. + * + * @static + * @param {Array[]} domain + * @param {integer} [limit] + * @param {integer[]} [moderated_channel_ids] + * @param {Object} [context] + * @returns {mail.message[]} + */ + static async performRpcMessageFetch(domain, limit, moderated_channel_ids, context) { + const messagesData = await this.env.services.rpc({ + model: 'mail.message', + method: 'message_fetch', + kwargs: { + context, + domain, + limit, + moderated_channel_ids, + }, + }, { shadow: true }); + const messages = this.env.models['mail.message'].insert(messagesData.map( + messageData => this.env.models['mail.message'].convertData(messageData) + )); + // compute seen indicators (if applicable) + for (const message of messages) { + for (const thread of message.threads) { + if (thread.model !== 'mail.channel' || thread.channel_type === 'channel') { + // disabled on non-channel threads and + // on `channel` channels for performance reasons + continue; + } + this.env.models['mail.message_seen_indicator'].insert({ + channelId: thread.id, + messageId: message.id, + }); + } + } + return messages; + } + + /** + * @static + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + static uncheckAll(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + threadCache.update({ checkedMessages: [['unlink', threadCache.messages]] }); + } + + /** + * Unstar all starred messages of current user. + */ + static async unstarAll() { + await this.env.services.rpc({ + model: 'mail.message', + method: 'unstar_all', + }); + } + + /** + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + * @returns {boolean} + */ + isChecked(thread, threadStringifiedDomain) { + // aku todo + const relatedCheckedThreadCache = this.checkedThreadCaches.find( + threadCache => ( + threadCache.thread === thread && + threadCache.stringifiedDomain === threadStringifiedDomain + ) + ); + return !!relatedCheckedThreadCache; + } + + /** + * Mark this message as read, so that it no longer appears in current + * partner Inbox. + */ + async markAsRead() { + await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'set_message_done', + args: [[this.id]] + })); + } + + /** + * Applies the moderation `decision` on this message. + * + * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject' + * @param {Object|undefined} [kwargs] optional data to pass on + * message moderation. This is provided when rejecting the messages + * for which title and comment give reason(s) for reject. + * @param {string} [kwargs.title] + * @param {string} [kwargs.comment] + */ + async moderate(decision, kwargs) { + await this.async(() => this.constructor.moderate([this], decision, kwargs)); + } + + /** + * Opens the view that allows to resend the message in case of failure. + */ + openResendAction() { + this.env.bus.trigger('do-action', { + action: 'mail.mail_resend_message_action', + options: { + additional_context: { + mail_message_to_resend: this.id, + }, + }, + }); + } + + /** + * Refreshes the value of `dateFromNow` field to the "current now". + */ + refreshDateFromNow() { + this.update({ dateFromNow: this._computeDateFromNow() }); + } + + /** + * Action to initiate reply to current message in Discuss Inbox. Assumes + * that Discuss and Inbox are already opened. + */ + replyTo() { + this.env.messaging.discuss.replyToMessage(this); + } + + /** + * Toggle check state of this message in the context of the provided + * thread and its stringifiedDomain. + * + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + toggleCheck(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + if (threadCache.checkedMessages.includes(this)) { + threadCache.update({ checkedMessages: [['unlink', this]] }); + } else { + threadCache.update({ checkedMessages: [['link', this]] }); + } + } + + /** + * Toggle the starred status of the provided message. + */ + async toggleStar() { + await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'toggle_message_starred', + args: [[this.id]] + })); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @returns {string} + */ + _computeDateFromNow() { + if (!this.date) { + return clear(); + } + return timeFromNow(this.date); + } + + /** + * @returns {boolean} + */ + _computeFailureNotifications() { + return [['replace', this.notifications.filter(notifications => + ['exception', 'bounce'].includes(notifications.notification_status) + )]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasCheckbox() { + return this.isModeratedByCurrentPartner; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerAuthor() { + return !!( + this.author && + this.messagingCurrentPartner && + this.messagingCurrentPartner === this.author + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsBodyEqualSubtypeDescription() { + if (!this.body || !this.subtype_description) { + return false; + } + const inlineBody = htmlToTextContentInline(this.body); + return inlineBody.toLowerCase() === this.subtype_description.toLowerCase(); + } + + /** + * The method does not attempt to cover all possible cases of empty + * messages, but mostly those that happen with a standard flow. Indeed + * it is preferable to be defensive and show an empty message sometimes + * instead of hiding a non-empty message. + * + * The main use case for when a message should become empty is for a + * message posted with only an attachment (no body) and then the + * attachment is deleted. + * + * The main use case for being defensive with the check is when + * receiving a message that has no textual content but has other + * meaningful HTML tags (eg. just an <img/>). + * + * @private + * @returns {boolean} + */ + _computeIsEmpty() { + const isBodyEmpty = ( + !this.body || + [ + '', + '<p></p>', + '<p><br></p>', + '<p><br/></p>', + ].includes(this.body.replace(/\s/g, '')) + ); + return ( + isBodyEmpty && + this.attachments.length === 0 && + this.tracking_value_ids.length === 0 && + !this.subtype_description + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsModeratedByCurrentPartner() { + return ( + this.moderation_status === 'pending_moderation' && + this.originThread && + this.originThread.isModeratedByCurrentPartner + ); + } + /** + * @private + * @returns {boolean} + */ + _computeIsSubjectSimilarToOriginThreadName() { + if ( + !this.subject || + !this.originThread || + !this.originThread.name + ) { + return false; + } + const threadName = this.originThread.name.toLowerCase().trim(); + const prefixList = ['re:', 'fw:', 'fwd:']; + let cleanedSubject = this.subject.toLowerCase(); + let wasSubjectCleaned = true; + while (wasSubjectCleaned) { + wasSubjectCleaned = false; + if (threadName === cleanedSubject) { + return true; + } + for (const prefix of prefixList) { + if (cleanedSubject.startsWith(prefix)) { + cleanedSubject = cleanedSubject.replace(prefix, '').trim(); + wasSubjectCleaned = true; + break; + } + } + } + return false; + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * This value is meant to be based on field body which is + * returned by the server (and has been sanitized before stored into db). + * Do not use this value in a 't-raw' if the message has been created + * directly from user input and not from server data as it's not escaped. + * + * @private + * @returns {string} + */ + _computePrettyBody() { + let prettyBody; + for (const emoji of emojis) { + const { unicode } = emoji; + const regexp = new RegExp( + `(?:^|\\s|<[a-z]*>)(${unicode})(?=\\s|$|</[a-z]*>)`, + "g" + ); + const originalBody = this.body; + prettyBody = this.body.replace( + regexp, + ` <span class="o_mail_emoji">${unicode}</span> ` + ); + // Idiot-proof limit. If the user had the amazing idea of + // copy-pasting thousands of emojis, the image rendering can lead + // to memory overflow errors on some browsers (e.g. Chrome). Set an + // arbitrary limit to 200 from which we simply don't replace them + // (anyway, they are already replaced by the unicode counterpart). + if (_.str.count(prettyBody, "o_mail_emoji") > 200) { + prettyBody = originalBody; + } + } + // add anchor tags to urls + return parseAndTransform(prettyBody, addLink); + } + + /** + * @private + * @returns {mail.thread[]} + */ + _computeThreads() { + const threads = [...this.serverChannels]; + if (this.isHistory) { + threads.push(this.env.messaging.history); + } + if (this.isNeedaction) { + threads.push(this.env.messaging.inbox); + } + if (this.isStarred) { + threads.push(this.env.messaging.starred); + } + if (this.env.messaging.moderation && this.isModeratedByCurrentPartner) { + threads.push(this.env.messaging.moderation); + } + if (this.originThread) { + threads.push(this.originThread); + } + return [['replace', threads]]; + } + + } + + Message.fields = { + attachments: many2many('mail.attachment', { + inverse: 'messages', + }), + author: many2one('mail.partner', { + inverse: 'messagesAsAuthor', + }), + /** + * This value is meant to be returned by the server + * (and has been sanitized before stored into db). + * Do not use this value in a 't-raw' if the message has been created + * directly from user input and not from server data as it's not escaped. + */ + body: attr({ + default: "", + }), + checkedThreadCaches: many2many('mail.thread_cache', { + inverse: 'checkedMessages', + }), + date: attr({ + default: moment(), + }), + /** + * States the time elapsed since date up to now. + */ + dateFromNow: attr({ + compute: '_computeDateFromNow', + dependencies: [ + 'date', + ], + }), + email_from: attr(), + failureNotifications: one2many('mail.notification', { + compute: '_computeFailureNotifications', + dependencies: ['notificationsStatus'], + }), + hasCheckbox: attr({ + compute: '_computeHasCheckbox', + default: false, + dependencies: ['isModeratedByCurrentPartner'], + }), + id: attr(), + isCurrentPartnerAuthor: attr({ + compute: '_computeIsCurrentPartnerAuthor', + default: false, + dependencies: [ + 'author', + 'messagingCurrentPartner', + ], + }), + /** + * States whether `body` and `subtype_description` contain similar + * values. + * + * This is necessary to avoid displaying both of them together when they + * contain duplicate information. This will especially happen with + * messages that are posted automatically at the creation of a record + * (messages that serve as tracking messages). They do have hard-coded + * "record created" body while being assigned a subtype with a + * description that states the same information. + * + * Fixing newer messages is possible by not assigning them a duplicate + * body content, but the check here is still necessary to handle + * existing messages. + * + * Limitations: + * - A translated subtype description might not match a non-translatable + * body created by a user with a different language. + * - Their content might be mostly but not exactly the same. + */ + isBodyEqualSubtypeDescription: attr({ + compute: '_computeIsBodyEqualSubtypeDescription', + default: false, + dependencies: [ + 'body', + 'subtype_description', + ], + }), + /** + * Determine whether the message has to be considered empty or not. + * + * An empty message has no text, no attachment and no tracking value. + */ + isEmpty: attr({ + compute: '_computeIsEmpty', + dependencies: [ + 'attachments', + 'body', + 'subtype_description', + 'tracking_value_ids', + ], + }), + isModeratedByCurrentPartner: attr({ + compute: '_computeIsModeratedByCurrentPartner', + default: false, + dependencies: [ + 'moderation_status', + 'originThread', + 'originThreadIsModeratedByCurrentPartner', + ], + }), + /** + * States whether `originThread.name` and `subject` contain similar + * values except it contains the extra prefix at the start + * of the subject. + * + * This is necessary to avoid displaying the subject, if + * the subject is same as threadname. + */ + isSubjectSimilarToOriginThreadName: attr({ + compute: '_computeIsSubjectSimilarToOriginThreadName', + dependencies: [ + 'originThread', + 'originThreadName', + 'subject', + ], + }), + isTemporary: attr({ + default: false, + }), + isTransient: attr({ + default: false, + }), + is_discussion: attr({ + default: false, + }), + /** + * Determine whether the message was a needaction. Useful to make it + * present in history mailbox. + */ + isHistory: attr({ + default: false, + }), + /** + * Determine whether the message is needaction. Useful to make it + * present in inbox mailbox and messaging menu. + */ + isNeedaction: attr({ + default: false, + }), + is_note: attr({ + default: false, + }), + is_notification: attr({ + default: false, + }), + /** + * Determine whether the message is starred. Useful to make it present + * in starred mailbox. + */ + isStarred: attr({ + default: false, + }), + message_type: attr(), + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + messagingCurrentPartner: many2one('mail.partner', { + related: 'messaging.currentPartner', + }), + messagingHistory: many2one('mail.thread', { + related: 'messaging.history', + }), + messagingInbox: many2one('mail.thread', { + related: 'messaging.inbox', + }), + messagingModeration: many2one('mail.thread', { + related: 'messaging.moderation', + }), + messagingStarred: many2one('mail.thread', { + related: 'messaging.starred', + }), + moderation_status: attr(), + notifications: one2many('mail.notification', { + inverse: 'message', + isCausal: true, + }), + notificationsStatus: attr({ + default: [], + related: 'notifications.notification_status', + }), + /** + * Origin thread of this message (if any). + */ + originThread: many2one('mail.thread', { + inverse: 'messagesAsOriginThread', + }), + originThreadIsModeratedByCurrentPartner: attr({ + default: false, + related: 'originThread.isModeratedByCurrentPartner', + }), + /** + * Serves as compute dependency for isSubjectSimilarToOriginThreadName + */ + originThreadName: attr({ + related: 'originThread.name', + }), + /** + * This value is meant to be based on field body which is + * returned by the server (and has been sanitized before stored into db). + * Do not use this value in a 't-raw' if the message has been created + * directly from user input and not from server data as it's not escaped. + */ + prettyBody: attr({ + compute: '_computePrettyBody', + dependencies: ['body'], + }), + subject: attr(), + subtype_description: attr(), + subtype_id: attr(), + /** + * All threads that this message is linked to. This field is read-only. + */ + threads: many2many('mail.thread', { + compute: '_computeThreads', + dependencies: [ + 'isHistory', + 'isModeratedByCurrentPartner', + 'isNeedaction', + 'isStarred', + 'messagingHistory', + 'messagingInbox', + 'messagingModeration', + 'messagingStarred', + 'originThread', + 'serverChannels', + ], + inverse: 'messages', + }), + tracking_value_ids: attr({ + default: [], + }), + /** + * All channels containing this message on the server. + * Equivalent of python field `channel_ids`. + */ + serverChannels: many2many('mail.thread', { + inverse: 'messagesAsServerChannel', + }), + }; + + Message.modelName = 'mail.message'; + + return Message; +} + +registerNewModel('mail.message', factory); + +}); diff --git a/addons/mail/static/src/models/message/message_tests.js b/addons/mail/static/src/models/message/message_tests.js new file mode 100644 index 00000000..054e8204 --- /dev/null +++ b/addons/mail/static/src/models/message/message_tests.js @@ -0,0 +1,187 @@ +odoo.define('mail/static/src/models/message/message_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +const { str_to_datetime } = require('web.time'); + +QUnit.module('mail', {}, function () { +QUnit.module('models', {}, function () { +QUnit.module('message', {}, function () { +QUnit.module('message_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('create', async function (assert) { + assert.expect(31); + + await this.start(); + assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 })); + assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + })); + assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.notOk(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 })); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'mail.channel', + name: "General", + }); + const message = this.env.models['mail.message'].create({ + attachments: [['insert-and-replace', { + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }]], + author: [['insert', { id: 5, display_name: "Demo" }]], + body: "<p>Test</p>", + date: moment(str_to_datetime("2019-05-05 10:00:00")), + id: 4000, + isNeedaction: true, + isStarred: true, + originThread: [['link', thread]], + }); + + assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 })); + assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + })); + assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 })); + assert.ok(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 })); + + assert.ok(message); + assert.strictEqual(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 }), message); + assert.strictEqual(message.body, "<p>Test</p>"); + assert.ok(message.date instanceof moment); + assert.strictEqual( + moment(message.date).utc().format('YYYY-MM-DD hh:mm:ss'), + "2019-05-05 10:00:00" + ); + assert.strictEqual(message.id, 4000); + assert.strictEqual(message.originThread, this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + })); + assert.ok( + message.threads.includes(this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + })) + ); + // from partnerId being in needaction_partner_ids + assert.ok(message.threads.includes(this.env.messaging.inbox)); + // from partnerId being in starred_partner_ids + assert.ok(message.threads.includes(this.env.messaging.starred)); + const attachment = this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }); + assert.ok(attachment); + assert.strictEqual(attachment.filename, "test.txt"); + assert.strictEqual(attachment.id, 750); + assert.notOk(attachment.isTemporary); + assert.strictEqual(attachment.mimetype, 'text/plain'); + assert.strictEqual(attachment.name, "test.txt"); + const channel = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + }); + assert.ok(channel); + assert.strictEqual(channel.model, 'mail.channel'); + assert.strictEqual(channel.id, 100); + assert.strictEqual(channel.name, "General"); + const partner = this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }); + assert.ok(partner); + assert.strictEqual(partner.display_name, "Demo"); + assert.strictEqual(partner.id, 5); +}); + +QUnit.test('message without body should be considered empty', async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ id: 11 }); + assert.ok(message.isEmpty); +}); + +QUnit.test('message with body "" should be considered empty', async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "", id: 11 }); + assert.ok(message.isEmpty); +}); + +QUnit.test('message with body "<p></p>" should be considered empty', async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "<p></p>", id: 11 }); + assert.ok(message.isEmpty); +}); + +QUnit.test('message with body "<p><br></p>" should be considered empty', async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "<p><br></p>", id: 11 }); + assert.ok(message.isEmpty); +}); + +QUnit.test('message with body "<p><br/></p>" should be considered empty', async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "<p><br/></p>", id: 11 }); + assert.ok(message.isEmpty); +}); + +QUnit.test(String.raw`message with body "<p>\n</p>" should be considered empty`, async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "<p>\n</p>", id: 11 }); + assert.ok(message.isEmpty); +}); + +QUnit.test(String.raw`message with body "<p>\r\n\r\n</p>" should be considered empty`, async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "<p>\r\n\r\n</p>", id: 11 }); + assert.ok(message.isEmpty); +}); + +QUnit.test('message with body "<p> </p> " should be considered empty', async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "<p> </p> ", id: 11 }); + assert.ok(message.isEmpty); +}); + +QUnit.test(`message with body "<img src=''>" should not be considered empty`, async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "<img src=''>", id: 11 }); + assert.notOk(message.isEmpty); +}); + +QUnit.test('message with body "test" should not be considered empty', async function (assert) { + assert.expect(1); + await this.start(); + const message = this.env.models['mail.message'].create({ body: "test", id: 11 }); + assert.notOk(message.isEmpty); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js new file mode 100644 index 00000000..dd1848aa --- /dev/null +++ b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js @@ -0,0 +1,358 @@ +odoo.define('mail/static/src/models/message_seen_indicator/message_seen_indicator.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class MessageSeenIndicator extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {mail.thread} [channel] the concerned thread + */ + static recomputeFetchedValues(channel = undefined) { + const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined; + const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction); + for (const indicator of indicators) { + indicator.update({ + hasEveryoneFetched: indicator._computeHasEveryoneFetched(), + hasSomeoneFetched: indicator._computeHasSomeoneFetched(), + partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(), + }); + } + } + + /** + * @static + * @param {mail.thread} [channel] the concerned thread + */ + static recomputeSeenValues(channel = undefined) { + const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined; + const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction); + for (const indicator of indicators) { + indicator.update({ + hasEveryoneSeen: indicator._computeHasEveryoneSeen(), + hasSomeoneFetched: indicator._computeHasSomeoneFetched(), + hasSomeoneSeen: indicator._computeHasSomeoneSeen(), + isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone: + indicator._computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone(), + partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(), + partnersThatHaveSeen: indicator._computePartnersThatHaveSeen(), + }); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + const { channelId, messageId } = data; + return `${this.modelName}_${channelId}_${messageId}`; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeFetchedValues + * @see computeSeenValues + */ + _computeHasEveryoneFetched() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return false; + } + const otherPartnerSeenInfosDidNotFetch = + this.thread.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + ( + !partnerSeenInfo.lastFetchedMessage || + partnerSeenInfo.lastFetchedMessage.id < this.message.id + ) + ); + return otherPartnerSeenInfosDidNotFetch.length === 0; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeSeenValues + */ + _computeHasEveryoneSeen() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return false; + } + const otherPartnerSeenInfosDidNotSee = + this.thread.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + ( + !partnerSeenInfo.lastSeenMessage || + partnerSeenInfo.lastSeenMessage.id < this.message.id + ) + ); + return otherPartnerSeenInfosDidNotSee.length === 0; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeFetchedValues + * @see computeSeenValues + */ + _computeHasSomeoneFetched() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return false; + } + const otherPartnerSeenInfosFetched = + this.thread.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + partnerSeenInfo.lastFetchedMessage && + partnerSeenInfo.lastFetchedMessage.id >= this.message.id + ); + return otherPartnerSeenInfosFetched.length > 0; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeSeenValues + */ + _computeHasSomeoneSeen() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return false; + } + const otherPartnerSeenInfosSeen = + this.thread.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + partnerSeenInfo.lastSeenMessage && + partnerSeenInfo.lastSeenMessage.id >= this.message.id + ); + return otherPartnerSeenInfosSeen.length > 0; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeSeenValues + */ + _computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone() { + if ( + !this.message || + !this.thread || + !this.thread.lastCurrentPartnerMessageSeenByEveryone + ) { + return false; + } + return this.message.id < this.thread.lastCurrentPartnerMessageSeenByEveryone.id; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {mail.partner[]} + * @see computeFetchedValues + * @see computeSeenValues + */ + _computePartnersThatHaveFetched() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return [['unlink-all']]; + } + const otherPartnersThatHaveFetched = this.thread.partnerSeenInfos + .filter(partnerSeenInfo => + /** + * Relation may not be set yet immediately + * @see mail.thread_partner_seen_info:partnerId field + * FIXME task-2278551 + */ + partnerSeenInfo.partner && + partnerSeenInfo.partner !== this.message.author && + partnerSeenInfo.lastFetchedMessage && + partnerSeenInfo.lastFetchedMessage.id >= this.message.id + ) + .map(partnerSeenInfo => partnerSeenInfo.partner); + if (otherPartnersThatHaveFetched.length === 0) { + return [['unlink-all']]; + } + return [['replace', otherPartnersThatHaveFetched]]; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {mail.partner[]} + * @see computeSeenValues + */ + _computePartnersThatHaveSeen() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return [['unlink-all']]; + } + const otherPartnersThatHaveSeen = this.thread.partnerSeenInfos + .filter(partnerSeenInfo => + /** + * Relation may not be set yet immediately + * @see mail.thread_partner_seen_info:partnerId field + * FIXME task-2278551 + */ + partnerSeenInfo.partner && + partnerSeenInfo.partner !== this.message.author && + partnerSeenInfo.lastSeenMessage && + partnerSeenInfo.lastSeenMessage.id >= this.message.id) + .map(partnerSeenInfo => partnerSeenInfo.partner); + if (otherPartnersThatHaveSeen.length === 0) { + return [['unlink-all']]; + } + return [['replace', otherPartnersThatHaveSeen]]; + } + + /** + * @private + * @returns {mail.message} + */ + _computeMessage() { + return [['insert', { id: this.messageId }]]; + } + + /** + * @private + * @returns {mail.thread} + */ + _computeThread() { + return [['insert', { + id: this.channelId, + model: 'mail.channel', + }]]; + } + } + + MessageSeenIndicator.modelName = 'mail.message_seen_indicator'; + + MessageSeenIndicator.fields = { + /** + * The id of the channel this seen indicator is related to. + * + * Should write on this field to set relation between the channel and + * this seen indicator, not on `thread`. + * + * Reason for not setting the relation directly is the necessity to + * uniquely identify a seen indicator based on channel and message from data. + * Relational data are list of commands, which is problematic to deduce + * identifying records. + * + * TODO: task-2322536 (normalize relational data) & task-2323665 + * (required fields) should improve and let us just use the relational + * fields. + */ + channelId: attr(), + hasEveryoneFetched: attr({ + compute: '_computeHasEveryoneFetched', + default: false, + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + hasEveryoneSeen: attr({ + compute: '_computeHasEveryoneSeen', + default: false, + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + hasSomeoneFetched: attr({ + compute: '_computeHasSomeoneFetched', + default: false, + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + hasSomeoneSeen: attr({ + compute: '_computeHasSomeoneSeen', + default: false, + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + id: attr(), + isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone: attr({ + compute: '_computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone', + default: false, + dependencies: [ + 'messageId', + 'threadLastCurrentPartnerMessageSeenByEveryone', + ], + }), + /** + * The message concerned by this seen indicator. + * This is automatically computed based on messageId field. + * @see messageId + */ + message: many2one('mail.message', { + compute: '_computeMessage', + dependencies: [ + 'messageId', + ], + }), + messageAuthor: many2one('mail.partner', { + related: 'message.author', + }), + /** + * The id of the message this seen indicator is related to. + * + * Should write on this field to set relation between the channel and + * this seen indicator, not on `message`. + * + * Reason for not setting the relation directly is the necessity to + * uniquely identify a seen indicator based on channel and message from data. + * Relational data are list of commands, which is problematic to deduce + * identifying records. + * + * TODO: task-2322536 (normalize relational data) & task-2323665 + * (required fields) should improve and let us just use the relational + * fields. + */ + messageId: attr(), + partnersThatHaveFetched: many2many('mail.partner', { + compute: '_computePartnersThatHaveFetched', + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + partnersThatHaveSeen: many2many('mail.partner', { + compute: '_computePartnersThatHaveSeen', + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + /** + * The thread concerned by this seen indicator. + * This is automatically computed based on channelId field. + * @see channelId + */ + thread: many2one('mail.thread', { + compute: '_computeThread', + dependencies: [ + 'channelId', + ], + inverse: 'messageSeenIndicators' + }), + threadPartnerSeenInfos: one2many('mail.thread_partner_seen_info', { + related: 'thread.partnerSeenInfos', + }), + threadLastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', { + related: 'thread.lastCurrentPartnerMessageSeenByEveryone', + }), + }; + + return MessageSeenIndicator; +} + +registerNewModel('mail.message_seen_indicator', factory); + +}); diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js new file mode 100644 index 00000000..3544e718 --- /dev/null +++ b/addons/mail/static/src/models/messaging/messaging.js @@ -0,0 +1,253 @@ +odoo.define('mail/static/src/models/messaging/messaging.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Messaging extends dependencies['mail.model'] { + + /** + * @override + */ + _willDelete() { + if (this.env.services['bus_service']) { + this.env.services['bus_service'].off('window_focus', null, this._handleGlobalWindowFocus); + } + return super._willDelete(...arguments); + } + + /** + * Starts messaging and related records. + */ + async start() { + this._handleGlobalWindowFocus = this._handleGlobalWindowFocus.bind(this); + this.env.services['bus_service'].on('window_focus', null, this._handleGlobalWindowFocus); + await this.async(() => this.initializer.start()); + this.notificationHandler.start(); + this.update({ isInitialized: true }); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @returns {boolean} + */ + isNotificationPermissionDefault() { + const windowNotification = this.env.browser.Notification; + return windowNotification + ? windowNotification.permission === 'default' + : false; + } + + /** + * Open the form view of the record with provided id and model. + * Gets the chat with the provided person and returns it. + * + * If a chat is not appropriate, a notification is displayed instead. + * + * @param {Object} param0 + * @param {integer} [param0.partnerId] + * @param {integer} [param0.userId] + * @param {Object} [options] + * @returns {mail.thread|undefined} + */ + async getChat({ partnerId, userId }) { + if (userId) { + const user = this.env.models['mail.user'].insert({ id: userId }); + return user.getChat(); + } + if (partnerId) { + const partner = this.env.models['mail.partner'].insert({ id: partnerId }); + return partner.getChat(); + } + } + + /** + * Opens a chat with the provided person and returns it. + * + * If a chat is not appropriate, a notification is displayed instead. + * + * @param {Object} person forwarded to @see `getChat()` + * @param {Object} [options] forwarded to @see `mail.thread:open()` + * @returns {mail.thread|undefined} + */ + async openChat(person, options) { + const chat = await this.async(() => this.getChat(person)); + if (!chat) { + return; + } + await this.async(() => chat.open(options)); + return chat; + } + + /** + * Opens the form view of the record with provided id and model. + * + * @param {Object} param0 + * @param {integer} param0.id + * @param {string} param0.model + */ + async openDocument({ id, model }) { + this.env.bus.trigger('do-action', { + action: { + type: 'ir.actions.act_window', + res_model: model, + views: [[false, 'form']], + res_id: id, + }, + }); + if (this.env.messaging.device.isMobile) { + // messaging menu has a higher z-index than views so it must + // be closed to ensure the visibility of the view + this.env.messaging.messagingMenu.close(); + } + } + + /** + * Opens the most appropriate view that is a profile for provided id and + * model. + * + * @param {Object} param0 + * @param {integer} param0.id + * @param {string} param0.model + */ + async openProfile({ id, model }) { + if (model === 'res.partner') { + const partner = this.env.models['mail.partner'].insert({ id }); + return partner.openProfile(); + } + if (model === 'res.users') { + const user = this.env.models['mail.user'].insert({ id }); + return user.openProfile(); + } + if (model === 'mail.channel') { + let channel = this.env.models['mail.thread'].findFromIdentifyingData({ id, model: 'mail.channel' }); + if (!channel) { + channel = (await this.async(() => + this.env.models['mail.thread'].performRpcChannelInfo({ ids: [id] }) + ))[0]; + } + if (!channel) { + this.env.services['notification'].notify({ + message: this.env._t("You can only open the profile of existing channels."), + type: 'warning', + }); + return; + } + return channel.openProfile(); + } + return this.env.messaging.openDocument({ id, model }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + */ + _handleGlobalWindowFocus() { + this.update({ outOfFocusUnreadMessageCounter: 0 }); + this.env.bus.trigger('set_title_part', { + part: '_chat', + }); + } + + } + + Messaging.fields = { + cannedResponses: one2many('mail.canned_response'), + chatWindowManager: one2one('mail.chat_window_manager', { + default: [['create']], + inverse: 'messaging', + isCausal: true, + }), + commands: one2many('mail.channel_command'), + currentPartner: one2one('mail.partner'), + currentUser: one2one('mail.user'), + device: one2one('mail.device', { + default: [['create']], + isCausal: true, + }), + dialogManager: one2one('mail.dialog_manager', { + default: [['create']], + isCausal: true, + }), + discuss: one2one('mail.discuss', { + default: [['create']], + inverse: 'messaging', + isCausal: true, + }), + /** + * Mailbox History. + */ + history: one2one('mail.thread'), + /** + * Mailbox Inbox. + */ + inbox: one2one('mail.thread'), + initializer: one2one('mail.messaging_initializer', { + default: [['create']], + inverse: 'messaging', + isCausal: true, + }), + isInitialized: attr({ + default: false, + }), + locale: one2one('mail.locale', { + default: [['create']], + isCausal: true, + }), + messagingMenu: one2one('mail.messaging_menu', { + default: [['create']], + inverse: 'messaging', + isCausal: true, + }), + /** + * Mailbox Moderation. + */ + moderation: one2one('mail.thread'), + notificationGroupManager: one2one('mail.notification_group_manager', { + default: [['create']], + isCausal: true, + }), + notificationHandler: one2one('mail.messaging_notification_handler', { + default: [['create']], + inverse: 'messaging', + isCausal: true, + }), + outOfFocusUnreadMessageCounter: attr({ + default: 0, + }), + partnerRoot: many2one('mail.partner'), + /** + * Determines which partner should be considered the public partner, + * which is a special partner notably used in livechat. + * + * @deprecated in favor of `publicPartners` because in multi-website + * setup there might be a different public partner per website. + */ + publicPartner: many2one('mail.partner'), + /** + * Determines which partners should be considered the public partners, + * which are special partners notably used in livechat. + */ + publicPartners: many2many('mail.partner'), + /** + * Mailbox Starred. + */ + starred: one2one('mail.thread'), + }; + + Messaging.modelName = 'mail.messaging'; + + return Messaging; +} + +registerNewModel('mail.messaging', factory); + +}); diff --git a/addons/mail/static/src/models/messaging/messaging_tests.js b/addons/mail/static/src/models/messaging/messaging_tests.js new file mode 100644 index 00000000..b306fbb1 --- /dev/null +++ b/addons/mail/static/src/models/messaging/messaging_tests.js @@ -0,0 +1,126 @@ +odoo.define('mail/static/src/models/messaging/messaging_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('models', {}, function () { +QUnit.module('messaging', {}, function () { +QUnit.module('messaging_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}, function () { + +QUnit.test('openChat: display notification for partner without user', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ id: 14 }); + await this.start(); + + await this.env.messaging.openChat({ partnerId: 14 }); + assert.containsOnce( + document.body, + '.toast .o_notification_content', + "should display a toast notification after failing to open chat" + ); + assert.strictEqual( + document.querySelector('.o_notification_content').textContent, + "You can only chat with partners that have a dedicated user.", + "should display the correct information in the notification" + ); +}); + +QUnit.test('openChat: display notification for wrong user', async function (assert) { + assert.expect(2); + + await this.start(); + + // user id not in this.data + await this.env.messaging.openChat({ userId: 14 }); + assert.containsOnce( + document.body, + '.toast .o_notification_content', + "should display a toast notification after failing to open chat" + ); + assert.strictEqual( + document.querySelector('.o_notification_content').textContent, + "You can only chat with existing users.", + "should display the correct information in the notification" + ); +}); + +QUnit.test('openChat: open new chat for user', async function (assert) { + assert.expect(3); + + this.data['res.partner'].records.push({ id: 14 }); + this.data['res.users'].records.push({ id: 11, partner_id: 14 }); + await this.start(); + + const existingChat = this.env.models['mail.thread'].find(thread => + thread.channel_type === 'chat' && + thread.correspondent && + thread.correspondent.id === 14 && + thread.model === 'mail.channel' && + thread.public === 'private' + ); + assert.notOk(existingChat, 'a chat should not exist with the target partner initially'); + + await this.env.messaging.openChat({ partnerId: 14 }); + const chat = this.env.models['mail.thread'].find(thread => + thread.channel_type === 'chat' && + thread.correspondent && + thread.correspondent.id === 14 && + thread.model === 'mail.channel' && + thread.public === 'private' + ); + assert.ok(chat, 'a chat should exist with the target partner'); + assert.strictEqual(chat.threadViews.length, 1, 'the chat should be displayed in a `mail.thread_view`'); +}); + +QUnit.test('openChat: open existing chat for user', async function (assert) { + assert.expect(5); + + this.data['res.partner'].records.push({ id: 14 }); + this.data['res.users'].records.push({ id: 11, partner_id: 14 }); + this.data['mail.channel'].records.push({ + channel_type: "chat", + id: 10, + members: [this.data.currentPartnerId, 14], + public: 'private', + }); + await this.start(); + const existingChat = this.env.models['mail.thread'].find(thread => + thread.channel_type === 'chat' && + thread.correspondent && + thread.correspondent.id === 14 && + thread.model === 'mail.channel' && + thread.public === 'private' + ); + assert.ok(existingChat, 'a chat should initially exist with the target partner'); + assert.strictEqual(existingChat.threadViews.length, 0, 'the chat should not be displayed in a `mail.thread_view`'); + + await this.env.messaging.openChat({ partnerId: 14 }); + assert.ok(existingChat, 'a chat should still exist with the target partner'); + assert.strictEqual(existingChat.id, 10, 'the chat should be the existing chat'); + assert.strictEqual(existingChat.threadViews.length, 1, 'the chat should now be displayed in a `mail.thread_view`'); +}); + +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js new file mode 100644 index 00000000..97d0d3b1 --- /dev/null +++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js @@ -0,0 +1,304 @@ +odoo.define('mail/static/src/models/messaging_initializer/messaging_initializer.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { one2one } = require('mail/static/src/model/model_field.js'); +const { executeGracefully } = require('mail/static/src/utils/utils.js'); + +function factory(dependencies) { + + class MessagingInitializer extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Fetch messaging data initially to populate the store specifically for + * the current user. This includes pinned channels for instance. + */ + async start() { + this.messaging.update({ + history: [['create', { + id: 'history', + isServerPinned: true, + model: 'mail.box', + name: this.env._t("History"), + }]], + inbox: [['create', { + id: 'inbox', + isServerPinned: true, + model: 'mail.box', + name: this.env._t("Inbox"), + }]], + moderation: [['create', { + id: 'moderation', + model: 'mail.box', + name: this.env._t("Moderation"), + }]], + starred: [['create', { + id: 'starred', + isServerPinned: true, + model: 'mail.box', + name: this.env._t("Starred"), + }]], + }); + const device = this.messaging.device; + device.start(); + const context = Object.assign({ + isMobile: device.isMobile, + }, this.env.session.user_context); + const discuss = this.messaging.discuss; + const data = await this.async(() => this.env.services.rpc({ + route: '/mail/init_messaging', + params: { context: context } + }, { shadow: true })); + await this.async(() => this._init(data)); + if (discuss.isOpen) { + discuss.openInitThread(); + } + if (this.env.autofetchPartnerImStatus) { + this.env.models['mail.partner'].startLoopFetchImStatus(); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @param {Object} param0 + * @param {Object} param0.channel_slots + * @param {Array} [param0.commands=[]] + * @param {Object} param0.current_partner + * @param {integer} param0.current_user_id + * @param {Object} [param0.mail_failures={}] + * @param {Object[]} [param0.mention_partner_suggestions=[]] + * @param {Object[]} [param0.moderation_channel_ids=[]] + * @param {integer} [param0.moderation_counter=0] + * @param {integer} [param0.needaction_inbox_counter=0] + * @param {Object} param0.partner_root + * @param {Object} param0.public_partner + * @param {Object[]} param0.public_partners + * @param {Object[]} [param0.shortcodes=[]] + * @param {integer} [param0.starred_counter=0] + */ + async _init({ + channel_slots, + commands = [], + current_partner, + current_user_id, + mail_failures = {}, + mention_partner_suggestions = [], + menu_id, + moderation_channel_ids = [], + moderation_counter = 0, + needaction_inbox_counter = 0, + partner_root, + public_partner, + public_partners, + shortcodes = [], + starred_counter = 0 + }) { + const discuss = this.messaging.discuss; + // partners first because the rest of the code relies on them + this._initPartners({ + current_partner, + current_user_id, + moderation_channel_ids, + partner_root, + public_partner, + public_partners, + }); + // mailboxes after partners and before other initializers that might + // manipulate threads or messages + this._initMailboxes({ + moderation_channel_ids, + moderation_counter, + needaction_inbox_counter, + starred_counter, + }); + // various suggestions in no particular order + this._initCannedResponses(shortcodes); + this._initCommands(commands); + this._initMentionPartnerSuggestions(mention_partner_suggestions); + // channels when the rest of messaging is ready + await this.async(() => this._initChannels(channel_slots)); + // failures after channels + this._initMailFailures(mail_failures); + discuss.update({ menu_id }); + } + + /** + * @private + * @param {Object[]} cannedResponsesData + */ + _initCannedResponses(cannedResponsesData) { + this.messaging.update({ + cannedResponses: [['insert', cannedResponsesData]], + }); + } + + /** + * @private + * @param {Object} [param0={}] + * @param {Object[]} [param0.channel_channel=[]] + * @param {Object[]} [param0.channel_direct_message=[]] + * @param {Object[]} [param0.channel_private_group=[]] + */ + async _initChannels({ + channel_channel = [], + channel_direct_message = [], + channel_private_group = [], + } = {}) { + const channelsData = channel_channel.concat(channel_direct_message, channel_private_group); + return executeGracefully(channelsData.map(channelData => () => { + const convertedData = this.env.models['mail.thread'].convertData(channelData); + if (!convertedData.members) { + // channel_info does not return all members of channel for + // performance reasons, but code is expecting to know at + // least if the current partner is member of it. + // (e.g. to know when to display "invited" notification) + // Current partner can always be assumed to be a member of + // channels received at init. + convertedData.members = [['link', this.env.messaging.currentPartner]]; + } + const channel = this.env.models['mail.thread'].insert( + Object.assign({ model: 'mail.channel' }, convertedData) + ); + // flux specific: channels received at init have to be + // considered pinned. task-2284357 + if (!channel.isPinned) { + channel.pin(); + } + })); + } + + /** + * @private + * @param {Object[]} commandsData + */ + _initCommands(commandsData) { + this.messaging.update({ + commands: [['insert', commandsData]], + }); + } + + /** + * @private + * @param {Object} param0 + * @param {Object[]} [param0.moderation_channel_ids=[]] + * @param {integer} param0.moderation_counter + * @param {integer} param0.needaction_inbox_counter + * @param {integer} param0.starred_counter + */ + _initMailboxes({ + moderation_channel_ids, + moderation_counter, + needaction_inbox_counter, + starred_counter, + }) { + this.env.messaging.inbox.update({ counter: needaction_inbox_counter }); + this.env.messaging.starred.update({ counter: starred_counter }); + if (moderation_channel_ids.length > 0) { + this.messaging.moderation.update({ + counter: moderation_counter, + isServerPinned: true, + }); + } + } + + /** + * @private + * @param {Object} mailFailuresData + */ + async _initMailFailures(mailFailuresData) { + await executeGracefully(mailFailuresData.map(messageData => () => { + const message = this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(messageData) + ); + // implicit: failures are sent by the server at initialization + // only if the current partner is author of the message + if (!message.author && this.messaging.currentPartner) { + message.update({ author: [['link', this.messaging.currentPartner]] }); + } + })); + this.messaging.notificationGroupManager.computeGroups(); + // manually force recompute of counter (after computing the groups) + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object[]} mentionPartnerSuggestionsData + */ + async _initMentionPartnerSuggestions(mentionPartnerSuggestionsData) { + return executeGracefully(mentionPartnerSuggestionsData.map(suggestions => () => { + return executeGracefully(suggestions.map(suggestion => () => { + this.env.models['mail.partner'].insert(this.env.models['mail.partner'].convertData(suggestion)); + })); + })); + } + + /** + * @private + * @param {Object} current_partner + * @param {integer} current_user_id + * @param {integer[]} moderation_channel_ids + * @param {Object} partner_root + * @param {Object} public_partner + * @param {Object[]} [public_partners=[]] + */ + _initPartners({ + current_partner, + current_user_id: currentUserId, + moderation_channel_ids = [], + partner_root, + public_partner, + public_partners = [], + }) { + const publicPartner = this.env.models['mail.partner'].convertData(public_partner); + this.messaging.update({ + currentPartner: [['insert', Object.assign( + this.env.models['mail.partner'].convertData(current_partner), + { + moderatedChannels: [ + ['insert', moderation_channel_ids.map(id => { + return { + id, + model: 'mail.channel', + }; + })], + ], + user: [['insert', { id: currentUserId }]], + } + )]], + currentUser: [['insert', { id: currentUserId }]], + partnerRoot: [['insert', this.env.models['mail.partner'].convertData(partner_root)]], + publicPartner: [['insert', publicPartner]], + publicPartners: [ + ['insert', publicPartner], + ['insert', public_partners.map( + publicPartner => this.env.models['mail.partner'].convertData(publicPartner)) + ], + ], + }); + } + + } + + MessagingInitializer.fields = { + messaging: one2one('mail.messaging', { + inverse: 'initializer', + }), + }; + + MessagingInitializer.modelName = 'mail.messaging_initializer'; + + return MessagingInitializer; +} + +registerNewModel('mail.messaging_initializer', factory); + +}); diff --git a/addons/mail/static/src/models/messaging_menu/messaging_menu.js b/addons/mail/static/src/models/messaging_menu/messaging_menu.js new file mode 100644 index 00000000..60212930 --- /dev/null +++ b/addons/mail/static/src/models/messaging_menu/messaging_menu.js @@ -0,0 +1,154 @@ +odoo.define('mail/static/src/models/messaging_menu/messaging_menu.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class MessagingMenu extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Close the messaging menu. Should reset its internal state. + */ + close() { + this.update({ isOpen: false }); + } + + /** + * Toggle the visibility of the messaging menu "new message" input in + * mobile. + */ + toggleMobileNewMessage() { + this.update({ isMobileNewMessageToggled: !this.isMobileNewMessageToggled }); + } + + /** + * Toggle whether the messaging menu is open or not. + */ + toggleOpen() { + this.update({ isOpen: !this.isOpen }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + */ + _computeInboxMessagesAutoloader() { + if (!this.isOpen) { + return; + } + const inbox = this.env.messaging.inbox; + if (!inbox || !inbox.mainCache) { + return; + } + // populate some needaction messages on threads. + inbox.mainCache.update({ isCacheRefreshRequested: true }); + } + + /** + * @private + * @returns {integer} + */ + _updateCounter() { + if (!this.env.messaging) { + return 0; + } + const inboxMailbox = this.env.messaging.inbox; + const unreadChannels = this.env.models['mail.thread'].all(thread => + thread.localMessageUnreadCounter > 0 && + thread.model === 'mail.channel' && + thread.isPinned + ); + let counter = unreadChannels.length; + if (inboxMailbox) { + counter += inboxMailbox.counter; + } + if (this.messaging.notificationGroupManager) { + counter += this.messaging.notificationGroupManager.groups.reduce( + (total, group) => total + group.notifications.length, + 0 + ); + } + if (this.messaging.isNotificationPermissionDefault()) { + counter++; + } + return counter; + } + + /** + * @override + */ + _updateAfter(previous) { + const counter = this._updateCounter(); + if (this.counter !== counter) { + this.update({ counter }); + } + } + + } + + MessagingMenu.fields = { + /** + * Tab selected in the messaging menu. + * Either 'all', 'chat' or 'channel'. + */ + activeTabId: attr({ + default: 'all', + }), + counter: attr({ + default: 0, + }), + /** + * Dummy field to automatically load messages of inbox when messaging + * menu is open. + * + * Useful because needaction notifications require fetching inbox + * messages to work. + */ + inboxMessagesAutoloader: attr({ + compute: '_computeInboxMessagesAutoloader', + dependencies: [ + 'isOpen', + 'messagingInbox', + 'messagingInboxMainCache', + ], + }), + /** + * Determine whether the mobile new message input is visible or not. + */ + isMobileNewMessageToggled: attr({ + default: false, + }), + /** + * Determine whether the messaging menu dropdown is open or not. + */ + isOpen: attr({ + default: false, + }), + messaging: one2one('mail.messaging', { + inverse: 'messagingMenu', + }), + messagingInbox: one2one('mail.thread', { + related: 'messaging.inbox', + }), + messagingInboxMainCache: one2one('mail.thread_cache', { + related: 'messagingInbox.mainCache', + }), + }; + + MessagingMenu.modelName = 'mail.messaging_menu'; + + return MessagingMenu; +} + +registerNewModel('mail.messaging_menu', factory); + +}); diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js new file mode 100644 index 00000000..a42ede1c --- /dev/null +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -0,0 +1,795 @@ +odoo.define('mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { one2one } = require('mail/static/src/model/model_field.js'); +const { decrement, increment } = require('mail/static/src/model/model_field_command.js'); +const { htmlToTextContentInline } = require('mail.utils'); + +const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers + +function factory(dependencies) { + + class MessagingNotificationHandler extends dependencies['mail.model'] { + + /** + * @override + */ + _willDelete() { + if (this.env.services['bus_service']) { + this.env.services['bus_service'].off('notification'); + this.env.services['bus_service'].stopPolling(); + } + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Fetch messaging data initially to populate the store specifically for + * the current users. This includes pinned channels for instance. + */ + start() { + this.env.services.bus_service.onNotification(null, notifs => this._handleNotifications(notifs)); + this.env.services.bus_service.startPolling(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @param {Object[]} notifications + * @returns {Object[]} + */ + _filterNotificationsOnUnsubscribe(notifications) { + const unsubscribedNotif = notifications.find(notif => + notif[1].info === 'unsubscribe'); + if (unsubscribedNotif) { + notifications = notifications.filter(notif => + notif[0][1] !== 'mail.channel' || + notif[0][2] !== unsubscribedNotif[1].id + ); + } + return notifications; + } + + /** + * @private + * @param {Object[]} notifications + * @param {Array|string} notifications[i][0] meta-data of the notification. + * @param {string} notifications[i][0][0] name of database this + * notification comes from. + * @param {string} notifications[i][0][1] type of notification. + * @param {integer} notifications[i][0][2] usually id of related type + * of notification. For instance, with `mail.channel`, this is the id + * of the channel. + * @param {Object} notifications[i][1] payload of the notification + */ + async _handleNotifications(notifications) { + const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications); + const proms = filteredNotifications.map(notification => { + const [channel, message] = notification; + if (typeof channel === 'string') { + // uuid notification, only for (livechat) public handler + return; + } + const [, model, id] = channel; + switch (model) { + case 'ir.needaction': + return this._handleNotificationNeedaction(message); + case 'mail.channel': + return this._handleNotificationChannel(id, message); + case 'res.partner': + if (id !== this.env.messaging.currentPartner.id) { + // ignore broadcast to other partners + return; + } + return this._handleNotificationPartner(Object.assign({}, message)); + } + }); + await this.async(() => Promise.all(proms)); + } + + /** + * @private + * @param {integer} channelId + * @param {Object} data + * @param {string} [data.info] + * @param {boolean} [data.is_typing] + * @param {integer} [data.last_message_id] + * @param {integer} [data.partner_id] + */ + async _handleNotificationChannel(channelId, data) { + const { + info, + is_typing, + last_message_id, + partner_id, + partner_name, + } = data; + switch (info) { + case 'channel_fetched': + return this._handleNotificationChannelFetched(channelId, { + last_message_id, + partner_id, + }); + case 'channel_seen': + return this._handleNotificationChannelSeen(channelId, { + last_message_id, + partner_id, + }); + case 'typing_status': + return this._handleNotificationChannelTypingStatus(channelId, { + is_typing, + partner_id, + partner_name, + }); + default: + return this._handleNotificationChannelMessage(channelId, data); + } + } + + /** + * @private + * @param {integer} channelId + * @param {Object} param1 + * @param {integer} param1.last_message_id + * @param {integer} param1.partner_id + */ + async _handleNotificationChannelFetched(channelId, { + last_message_id, + partner_id, + }) { + const channel = this.env.models['mail.thread'].findFromIdentifyingData({ + id: channelId, + model: 'mail.channel', + }); + if (!channel) { + // for example seen from another browser, the current one has no + // knowledge of the channel + return; + } + if (channel.channel_type === 'channel') { + // disabled on `channel` channels for performance reasons + return; + } + this.env.models['mail.thread_partner_seen_info'].insert({ + channelId: channel.id, + lastFetchedMessage: [['insert', { id: last_message_id }]], + partnerId: partner_id, + }); + channel.update({ + messageSeenIndicators: [['insert', + { + channelId: channel.id, + messageId: last_message_id, + } + ]], + }); + // FIXME force the computing of message values (cf task-2261221) + this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel); + } + + /** + * @private + * @param {integer} channelId + * @param {Object} messageData + */ + async _handleNotificationChannelMessage(channelId, messageData) { + let channel = this.env.models['mail.thread'].findFromIdentifyingData({ + id: channelId, + model: 'mail.channel', + }); + const wasChannelExisting = !!channel; + const convertedData = this.env.models['mail.message'].convertData(messageData); + const oldMessage = this.env.models['mail.message'].findFromIdentifyingData(convertedData); + // locally save old values, as insert would overwrite them + const oldMessageModerationStatus = ( + oldMessage && oldMessage.moderation_status + ); + const oldMessageWasModeratedByCurrentPartner = ( + oldMessage && oldMessage.isModeratedByCurrentPartner + ); + + // Fetch missing info from channel before going further. Inserting + // a channel with incomplete info can lead to issues. This is in + // particular the case with the `uuid` field that is assumed + // "required" by the rest of the code and is necessary for some + // features such as chat windows. + if (!channel) { + channel = (await this.async(() => + this.env.models['mail.thread'].performRpcChannelInfo({ ids: [channelId] }) + ))[0]; + } + if (!channel.isPinned) { + channel.pin(); + } + + const message = this.env.models['mail.message'].insert(convertedData); + this._notifyThreadViewsMessageReceived(message); + + // If the message was already known: nothing else should be done, + // except if it was pending moderation by the current partner, then + // decrement the moderation counter. + if (oldMessage) { + if ( + oldMessageModerationStatus === 'pending_moderation' && + message.moderation_status !== 'pending_moderation' && + oldMessageWasModeratedByCurrentPartner + ) { + const moderation = this.env.messaging.moderation; + moderation.update({ counter: decrement() }); + } + return; + } + + // If the current partner is author, do nothing else. + if (message.author === this.env.messaging.currentPartner) { + return; + } + + // Message from mailing channel should not make a notification in + // Odoo for users with notification "Handled by Email". + // Channel has been marked as read server-side in this case, so + // it should not display a notification by incrementing the + // unread counter. + if ( + channel.mass_mailing && + this.env.session.notification_type === 'email' + ) { + this._handleNotificationChannelSeen(channelId, { + last_message_id: messageData.id, + partner_id: this.env.messaging.currentPartner.id, + }); + return; + } + // In all other cases: update counter and notify if necessary + + // Chat from OdooBot is considered disturbing and should only be + // shown on the menu, but no notification and no thread open. + const isChatWithOdooBot = ( + channel.correspondent && + channel.correspondent === this.env.messaging.partnerRoot + ); + if (!isChatWithOdooBot) { + const isOdooFocused = this.env.services['bus_service'].isOdooFocused(); + // Notify if out of focus + if (!isOdooFocused && channel.isChatChannel) { + this._notifyNewChannelMessageWhileOutOfFocus({ + channel, + message, + }); + } + if (channel.model === 'mail.channel' && channel.channel_type !== 'channel') { + // disabled on non-channel threads and + // on `channel` channels for performance reasons + channel.markAsFetched(); + } + // open chat on receiving new message if it was not already opened or folded + if (channel.channel_type !== 'channel' && !this.env.messaging.device.isMobile && !channel.chatWindow) { + this.env.messaging.chatWindowManager.openThread(channel); + } + } + + // If the channel wasn't known its correct counter was fetched at + // the start of the method, no need update it here. + if (!wasChannelExisting) { + return; + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * Called when a channel has been seen, and the server responds with the + * last message seen. Useful in order to track last message seen. + * + * @private + * @param {integer} channelId + * @param {Object} param1 + * @param {integer} param1.last_message_id + * @param {integer} param1.partner_id + */ + async _handleNotificationChannelSeen(channelId, { + last_message_id, + partner_id, + }) { + const channel = this.env.models['mail.thread'].findFromIdentifyingData({ + id: channelId, + model: 'mail.channel', + }); + if (!channel) { + // for example seen from another browser, the current one has no + // knowledge of the channel + return; + } + const lastMessage = this.env.models['mail.message'].insert({ id: last_message_id }); + // restrict computation of seen indicator for "non-channel" channels + // for performance reasons + const shouldComputeSeenIndicators = channel.channel_type !== 'channel'; + const updateData = {}; + if (shouldComputeSeenIndicators) { + this.env.models['mail.thread_partner_seen_info'].insert({ + channelId: channel.id, + lastSeenMessage: [['link', lastMessage]], + partnerId: partner_id, + }); + Object.assign(updateData, { + // FIXME should no longer use computeId (task-2335647) + messageSeenIndicators: [['insert', + { + channelId: channel.id, + messageId: lastMessage.id, + }, + ]], + }); + } + if (this.env.messaging.currentPartner.id === partner_id) { + Object.assign(updateData, { + lastSeenByCurrentPartnerMessageId: last_message_id, + pendingSeenMessageId: undefined, + }); + } + channel.update(updateData); + if (shouldComputeSeenIndicators) { + // FIXME force the computing of thread values (cf task-2261221) + this.env.models['mail.thread'].computeLastCurrentPartnerMessageSeenByEveryone(channel); + // FIXME force the computing of message values (cf task-2261221) + this.env.models['mail.message_seen_indicator'].recomputeSeenValues(channel); + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {integer} channelId + * @param {Object} param1 + * @param {boolean} param1.is_typing + * @param {integer} param1.partner_id + * @param {string} param1.partner_name + */ + _handleNotificationChannelTypingStatus(channelId, { is_typing, partner_id, partner_name }) { + const channel = this.env.models['mail.thread'].findFromIdentifyingData({ + id: channelId, + model: 'mail.channel', + }); + if (!channel) { + return; + } + const partner = this.env.models['mail.partner'].insert({ + id: partner_id, + name: partner_name, + }); + if (partner === this.env.messaging.currentPartner) { + // Ignore management of current partner is typing notification. + return; + } + if (is_typing) { + if (channel.typingMembers.includes(partner)) { + channel.refreshOtherMemberTypingMember(partner); + } else { + channel.registerOtherMemberTypingMember(partner); + } + } else { + if (!channel.typingMembers.includes(partner)) { + // Ignore no longer typing notifications of members that + // are not registered as typing something. + return; + } + channel.unregisterOtherMemberTypingMember(partner); + } + } + + /** + * @private + * @param {Object} data + */ + _handleNotificationNeedaction(data) { + const message = this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data) + ); + this.env.messaging.inbox.update({ counter: increment() }); + const originThread = message.originThread; + if (originThread && message.isNeedaction) { + originThread.update({ message_needaction_counter: increment() }); + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} data + * @param {string} [data.info] + * @param {string} [data.type] + */ + async _handleNotificationPartner(data) { + const { + info, + type, + } = data; + if (type === 'activity_updated') { + this.env.bus.trigger('activity_updated', data); + } else if (type === 'author') { + return this._handleNotificationPartnerAuthor(data); + } else if (info === 'channel_seen') { + return this._handleNotificationChannelSeen(data.channel_id, data); + } else if (type === 'deletion') { + return this._handleNotificationPartnerDeletion(data); + } else if (type === 'message_notification_update') { + return this._handleNotificationPartnerMessageNotificationUpdate(data.elements); + } else if (type === 'mark_as_read') { + return this._handleNotificationPartnerMarkAsRead(data); + } else if (type === 'moderator') { + return this._handleNotificationPartnerModerator(data); + } else if (type === 'simple_notification') { + const escapedMessage = owl.utils.escape(data.message); + this.env.services['notification'].notify({ + message: escapedMessage, + sticky: data.sticky, + type: data.warning ? 'warning' : 'danger', + }); + } else if (type === 'toggle_star') { + return this._handleNotificationPartnerToggleStar(data); + } else if (info === 'transient_message') { + return this._handleNotificationPartnerTransientMessage(data); + } else if (info === 'unsubscribe') { + return this._handleNotificationPartnerUnsubscribe(data.id); + } else if (type === 'user_connection') { + return this._handleNotificationPartnerUserConnection(data); + } else if (!type) { + return this._handleNotificationPartnerChannel(data); + } + } + + /** + * @private + * @param {Object} data + * @param {Object} data.message + */ + _handleNotificationPartnerAuthor(data) { + this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data.message) + ); + } + + /** + * @private + * @param {Object} data + * @param {string} data.channel_type + * @param {integer} data.id + * @param {string} [data.info] + * @param {boolean} data.is_minimized + * @param {string} data.name + * @param {string} data.state + * @param {string} data.uuid + */ + _handleNotificationPartnerChannel(data) { + const convertedData = this.env.models['mail.thread'].convertData( + Object.assign({ model: 'mail.channel' }, data) + ); + if (!convertedData.members) { + // channel_info does not return all members of channel for + // performance reasons, but code is expecting to know at + // least if the current partner is member of it. + // (e.g. to know when to display "invited" notification) + // Current partner can always be assumed to be a member of + // channels received through this notification. + convertedData.members = [['link', this.env.messaging.currentPartner]]; + } + let channel = this.env.models['mail.thread'].findFromIdentifyingData(convertedData); + const wasCurrentPartnerMember = ( + channel && + channel.members.includes(this.env.messaging.currentPartner) + ); + + channel = this.env.models['mail.thread'].insert(convertedData); + if ( + channel.channel_type === 'channel' && + data.info !== 'creation' && + !wasCurrentPartnerMember + ) { + this.env.services['notification'].notify({ + message: _.str.sprintf( + this.env._t("You have been invited to: %s"), + owl.utils.escape(channel.name) + ), + type: 'warning', + }); + } + // a new thread with unread messages could have been added + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} param0.messag_ids + */ + _handleNotificationPartnerDeletion({ message_ids }) { + const moderationMailbox = this.env.messaging.moderation; + for (const id of message_ids) { + const message = this.env.models['mail.message'].findFromIdentifyingData({ id }); + if (message) { + if ( + message.moderation_status === 'pending_moderation' && + message.originThread.isModeratedByCurrentPartner + ) { + moderationMailbox.update({ counter: decrement() }); + } + message.delete(); + } + } + // deleting message might have deleted notifications, force recompute + this.messaging.notificationGroupManager.computeGroups(); + // manually force recompute of counter (after computing the groups) + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} data + */ + _handleNotificationPartnerMessageNotificationUpdate(data) { + for (const messageData of data) { + const message = this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(messageData) + ); + // implicit: failures are sent by the server as notification + // only if the current partner is author of the message + if (!message.author && this.messaging.currentPartner) { + message.update({ author: [['link', this.messaging.currentPartner]] }); + } + } + this.messaging.notificationGroupManager.computeGroups(); + // manually force recompute of counter (after computing the groups) + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} [param0.channel_ids + * @param {integer[]} [param0.message_ids=[]] + * @param {integer} [param0.needaction_inbox_counter] + */ + _handleNotificationPartnerMarkAsRead({ channel_ids, message_ids = [], needaction_inbox_counter }) { + for (const message_id of message_ids) { + // We need to ignore all not yet known messages because we don't want them + // to be shown partially as they would be linked directly to mainCache + // Furthermore, server should not send back all message_ids marked as read + // but something like last read message_id or something like that. + // (just imagine you mark 1000 messages as read ... ) + const message = this.env.models['mail.message'].findFromIdentifyingData({ id: message_id }); + if (!message) { + continue; + } + // update thread counter + const originThread = message.originThread; + if (originThread && message.isNeedaction) { + originThread.update({ message_needaction_counter: decrement() }); + } + // move messages from Inbox to history + message.update({ + isHistory: true, + isNeedaction: false, + }); + } + const inbox = this.env.messaging.inbox; + if (needaction_inbox_counter !== undefined) { + inbox.update({ counter: needaction_inbox_counter }); + } else { + // kept for compatibility in stable + inbox.update({ counter: decrement(message_ids.length) }); + } + if (inbox.counter > inbox.mainCache.fetchedMessages.length) { + // Force refresh Inbox because depending on what was marked as + // read the cache might become empty even though there are more + // messages on the server. + inbox.mainCache.update({ hasToLoadMessages: true }); + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {Object} param0.message + */ + _handleNotificationPartnerModerator({ message: data }) { + this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data) + ); + const moderationMailbox = this.env.messaging.moderation; + if (moderationMailbox) { + moderationMailbox.update({ counter: increment() }); + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} param0.message_ids + * @param {boolean} param0.starred + */ + _handleNotificationPartnerToggleStar({ message_ids = [], starred }) { + const starredMailbox = this.env.messaging.starred; + for (const messageId of message_ids) { + const message = this.env.models['mail.message'].findFromIdentifyingData({ + id: messageId, + }); + if (!message) { + continue; + } + message.update({ isStarred: starred }); + starredMailbox.update({ + counter: starred ? increment() : decrement(), + }); + } + } + + /** + * On receiving a transient message, i.e. a message which does not come + * from a member of the channel. Usually a log message, such as one + * generated from a command with ('/'). + * + * @private + * @param {Object} data + */ + _handleNotificationPartnerTransientMessage(data) { + const convertedData = this.env.models['mail.message'].convertData(data); + const lastMessageId = this.env.models['mail.message'].all().reduce( + (lastMessageId, message) => Math.max(lastMessageId, message.id), + 0 + ); + const partnerRoot = this.env.messaging.partnerRoot; + const message = this.env.models['mail.message'].create(Object.assign(convertedData, { + author: [['link', partnerRoot]], + id: lastMessageId + 0.01, + isTransient: true, + })); + this._notifyThreadViewsMessageReceived(message); + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {integer} channelId + */ + _handleNotificationPartnerUnsubscribe(channelId) { + const channel = this.env.models['mail.thread'].findFromIdentifyingData({ + id: channelId, + model: 'mail.channel', + }); + if (!channel) { + return; + } + let message; + if (channel.correspondent) { + const correspondent = channel.correspondent; + message = _.str.sprintf( + this.env._t("You unpinned your conversation with <b>%s</b>."), + owl.utils.escape(correspondent.name) + ); + } else { + message = _.str.sprintf( + this.env._t("You unsubscribed from <b>%s</b>."), + owl.utils.escape(channel.name) + ); + } + // We assume that arriving here the server has effectively + // unpinned the channel + channel.update({ isServerPinned: false }); + this.env.services['notification'].notify({ + message, + type: 'warning', + }); + } + + /** + * @private + * @param {Object} param0 + * @param {string} param0.message + * @param {integer} param0.partner_id + * @param {string} param0.title + */ + async _handleNotificationPartnerUserConnection({ message, partner_id, title }) { + // If the current user invited a new user, and the new user is + // connecting for the first time while the current user is present + // then open a chat for the current user with the new user. + this.env.services['bus_service'].sendNotification(title, message); + const chat = await this.async(() => + this.env.messaging.getChat({ partnerId: partner_id } + )); + if (!chat || this.env.messaging.device.isMobile) { + return; + } + this.env.messaging.chatWindowManager.openThread(chat); + } + + /** + * @private + * @param {Object} param0 + * @param {mail.thread} param0.channel + * @param {mail.message} param0.message + */ + _notifyNewChannelMessageWhileOutOfFocus({ channel, message }) { + const author = message.author; + const messaging = this.env.messaging; + let notificationTitle; + if (!author) { + notificationTitle = this.env._t("New message"); + } else { + const authorName = author.nameOrDisplayName; + if (channel.channel_type === 'channel') { + // hack: notification template does not support OWL components, + // so we simply use their template to make HTML as if it comes + // from component + const channelIcon = this.env.qweb.renderToString('mail.ThreadIcon', { + env: this.env, + thread: channel, + }); + const channelName = owl.utils.escape(channel.displayName); + const channelNameWithIcon = channelIcon + channelName; + notificationTitle = _.str.sprintf( + this.env._t("%s from %s"), + owl.utils.escape(authorName), + channelNameWithIcon + ); + } else { + notificationTitle = owl.utils.escape(authorName); + } + } + const notificationContent = htmlToTextContentInline(message.body).substr(0, PREVIEW_MSG_MAX_SIZE); + this.env.services['bus_service'].sendNotification(notificationTitle, notificationContent); + messaging.update({ outOfFocusUnreadMessageCounter: increment() }); + const titlePattern = messaging.outOfFocusUnreadMessageCounter === 1 + ? this.env._t("%d Message") + : this.env._t("%d Messages"); + this.env.bus.trigger('set_title_part', { + part: '_chat', + title: _.str.sprintf(titlePattern, messaging.outOfFocusUnreadMessageCounter), + }); + } + + /** + * Notifies threadViews about the given message being just received. + * This can allow them adjust their scroll position if applicable. + * + * @private + * @param {mail.message} + */ + _notifyThreadViewsMessageReceived(message) { + for (const thread of message.threads) { + for (const threadView of thread.threadViews) { + threadView.addComponentHint('message-received', { message }); + } + } + } + + } + + MessagingNotificationHandler.fields = { + messaging: one2one('mail.messaging', { + inverse: 'notificationHandler', + }), + }; + + MessagingNotificationHandler.modelName = 'mail.messaging_notification_handler'; + + return MessagingNotificationHandler; +} + +registerNewModel('mail.messaging_notification_handler', factory); + +}); diff --git a/addons/mail/static/src/models/model/model.js b/addons/mail/static/src/models/model/model.js new file mode 100644 index 00000000..3696332a --- /dev/null +++ b/addons/mail/static/src/models/model/model.js @@ -0,0 +1,291 @@ +odoo.define('mail/static/src/models/Model', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { RecordDeletedError } = require('mail/static/src/model/model_errors.js'); + +/** + * This function generates a class that represent a model. Instances of such + * model (or inherited models) represent logical objects used in whole + * application. They could represent server record (e.g. Thread, Message) or + * UI elements (e.g. MessagingMenu, ChatWindow). These instances are called + * "records", while the classes are called "models". + */ +function factory() { + + class Model { + + /** + * @param {Object} [param0={}] + * @param {boolean} [param0.valid=false] if set, this constructor is + * called by static method `create()`. This should always be the case. + * @throws {Error} in case constructor is called in an invalid way, i.e. + * by instantiating the record manually with `new` instead of from + * static method `create()`. + */ + constructor({ valid = false } = {}) { + if (!valid) { + throw new Error("Record must always be instantiated from static method 'create()'"); + } + } + + /** + * This function is called during the create cycle, when the record has + * already been created, but its values have not yet been assigned. + * + * It is usually preferable to override @see `_created`. + * + * The main use case is to prepare the record for the assignation of its + * values, for example if a computed field relies on the record to have + * some purely technical property correctly set. + * + * @abstract + * @private + */ + _willCreate() {} + + /** + * This function is called after the record has been created, more + * precisely at the end of the update cycle (which means all implicit + * changes such as computes have been applied too). + * + * The main use case is to register listeners on the record. + * + * @abstract + * @private + */ + _created() {} + + /** + * This function is called when the record is about to be deleted. The + * record still has all of its fields values accessible, but for all + * intents and purposes the record should already be considered + * deleted, which means update shouldn't be called inside this method. + * + * The main use case is to unregister listeners on the record. + * + * @abstract + * @private + */ + _willDelete() {} + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Returns all records of this model that match provided criteria. + * + * @static + * @param {function} [filterFunc] + * @returns {mail.model[]} + */ + static all(filterFunc) { + return this.env.modelManager.all(this, filterFunc); + } + + /** + * This method is used to create new records of this model + * with provided data. This is the only way to create them: + * instantiation must never been done with keyword `new` outside of this + * function, otherwise the record will not be registered. + * + * @static + * @param {Object|Object[]} [data] data object with initial data, including relations. + * If data is an iterable, multiple records will be created. + * @returns {mail.model|mail.model[]} newly created record(s) + */ + static create(data) { + return this.env.modelManager.create(this, data); + } + + /** + * Get the record that has provided criteria, if it exists. + * + * @static + * @param {function} findFunc + * @returns {mail.model|undefined} + */ + static find(findFunc) { + return this.env.modelManager.find(this, findFunc); + } + + /** + * Gets the unique record that matches the given identifying data, if it + * exists. + * @see `_createRecordLocalId` for criteria of identification. + * + * @static + * @param {Object} data + * @returns {mail.model|undefined} + */ + static findFromIdentifyingData(data) { + return this.env.modelManager.findFromIdentifyingData(this, data); + } + + /** + * This method returns the record of this model that matches provided + * local id. Useful to convert a local id to a record. Note that even + * if there's a record in the system having provided local id, if the + * resulting record is not an instance of this model, this getter + * assumes the record does not exist. + * + * @static + * @param {string} localId + * @param {Object} param1 + * @param {boolean} [param1.isCheckingInheritance] + * @returns {mail.model|undefined} + */ + static get(localId, { isCheckingInheritance } = {}) { + return this.env.modelManager.get(this, localId, { isCheckingInheritance }); + } + + /** + * This method creates a record or updates one, depending + * on provided data. + * + * @static + * @param {Object|Object[]} data + * If data is an iterable, multiple records will be created/updated. + * @returns {mail.model|mail.model[]} created or updated record(s). + */ + static insert(data) { + return this.env.modelManager.insert(this, data); + } + + /** + * Perform an async function and wait until it is done. If the record + * is deleted, it raises a RecordDeletedError. + * + * @param {function} func an async function + * @throws {RecordDeletedError} in case the current record is not alive + * at the end of async function call, whether it's resolved or + * rejected. + * @throws {any} forwards any error in case the current record is still + * alive at the end of rejected async function call. + * @returns {any} result of resolved async function. + */ + async async(func) { + return new Promise((resolve, reject) => { + Promise.resolve(func()).then(result => { + if (this.exists()) { + resolve(result); + } else { + reject(new RecordDeletedError(this.localId)); + } + }).catch(error => { + if (this.exists()) { + reject(error); + } else { + reject(new RecordDeletedError(this.localId)); + } + }); + }); + } + + /** + * This method deletes this record. + */ + delete() { + this.env.modelManager.delete(this); + } + + /** + * Returns whether the current record exists. + * + * @returns {boolean} + */ + exists() { + return this.env.modelManager.exists(this.constructor, this); + } + + /** + * Update this record with provided data. + * + * @param {Object} [data={}] + */ + update(data = {}) { + this.env.modelManager.update(this, data); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * This method generates a local id for this record that is + * being created at the moment. + * + * This function helps customizing the local id to ease mapping a local + * id to its record for the developer that reads the local id. For + * instance, the local id of a thread cache could combine the thread + * and stringified domain in its local id, which is much easier to + * track relations and records in the system instead of arbitrary + * number to differenciate them. + * + * @static + * @private + * @param {Object} data + * @returns {string} + */ + static _createRecordLocalId(data) { + return _.uniqueId(`${this.modelName}_`); + } + + /** + * This function is called when this record has been explicitly updated + * with `.update()` or static method `.create()`, at the end of an + * record update cycle. This is a backward-compatible behaviour that + * is deprecated: you should use computed fields instead. + * + * @deprecated + * @abstract + * @private + * @param {Object} previous contains data that have been stored by + * `_updateBefore()`. Useful to make extra update decisions based on + * previous data. + */ + _updateAfter(previous) {} + + /** + * This function is called just at the beginning of an explicit update + * on this function, with `.update()` or static method `.create()`. This + * is useful to remember previous values of fields in `_updateAfter`. + * This is a backward-compatible behaviour that is deprecated: you + * should use computed fields instead. + * + * @deprecated + * @abstract + * @private + * @param {Object} data + * @returns {Object} + */ + _updateBefore() { + return {}; + } + + } + + /** + * Models should define fields in static prop or getter `fields`. + * It contains an object with name of field as key and value are objects + * that define the field. There are some helpers to ease the making of these + * objects, @see `mail/static/src/model/model_field.js` + * + * Note: fields of super-class are automatically inherited, therefore a + * sub-class should (re-)define fields without copying ancestors' fields. + */ + Model.fields = {}; + + /** + * Name of the model. Important to refer to appropriate model class + * like in relational fields. Name of model classes must be unique. + */ + Model.modelName = 'mail.model'; + + return Model; +} + +registerNewModel('mail.model', factory); + +}); diff --git a/addons/mail/static/src/models/notification/notification.js b/addons/mail/static/src/models/notification/notification.js new file mode 100644 index 00000000..047faee9 --- /dev/null +++ b/addons/mail/static/src/models/notification/notification.js @@ -0,0 +1,80 @@ +odoo.define('mail/static/src/models/notification/notification.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Notification extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('failure_type' in data) { + data2.failure_type = data.failure_type; + } + if ('id' in data) { + data2.id = data.id; + } + if ('notification_status' in data) { + data2.notification_status = data.notification_status; + } + if ('notification_type' in data) { + data2.notification_type = data.notification_type; + } + if ('res_partner_id' in data) { + if (!data.res_partner_id) { + data2.partner = [['unlink-all']]; + } else { + data2.partner = [ + ['insert', { + display_name: data.res_partner_id[1], + id: data.res_partner_id[0], + }], + ]; + } + } + return data2; + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + } + + Notification.fields = { + failure_type: attr(), + id: attr(), + message: many2one('mail.message', { + inverse: 'notifications', + }), + notification_status: attr(), + notification_type: attr(), + partner: many2one('mail.partner'), + }; + + Notification.modelName = 'mail.notification'; + + return Notification; +} + +registerNewModel('mail.notification', factory); + +}); diff --git a/addons/mail/static/src/models/notification_group/notification_group.js b/addons/mail/static/src/models/notification_group/notification_group.js new file mode 100644 index 00000000..89111a4e --- /dev/null +++ b/addons/mail/static/src/models/notification_group/notification_group.js @@ -0,0 +1,126 @@ +odoo.define('mail/static/src/models/notification_group/notification_group.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class NotificationGroup extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Opens the view that allows to cancel all notifications of the group. + */ + openCancelAction() { + if (this.notification_type !== 'email') { + return; + } + this.env.bus.trigger('do-action', { + action: 'mail.mail_resend_cancel_action', + options: { + additional_context: { + default_model: this.res_model, + unread_counter: this.notifications.length, + }, + }, + }); + } + + /** + * Opens the view that displays either the single record of the group or + * all the records in the group. + */ + openDocuments() { + if (this.thread) { + this.thread.open(); + } else { + this._openDocuments(); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.thread|undefined} + */ + _computeThread() { + if (this.res_id) { + return [['insert', { + id: this.res_id, + model: this.res_model, + }]]; + } + return [['unlink']]; + } + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * Opens the view that displays all the records of the group. + * + * @private + */ + _openDocuments() { + if (this.notification_type !== 'email') { + return; + } + this.env.bus.trigger('do-action', { + action: { + name: this.env._t("Mail Failures"), + type: 'ir.actions.act_window', + view_mode: 'kanban,list,form', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], + target: 'current', + res_model: this.res_model, + domain: [['message_has_error', '=', true]], + }, + }); + if (this.env.messaging.device.isMobile) { + // messaging menu has a higher z-index than views so it must + // be closed to ensure the visibility of the view + this.env.messaging.messagingMenu.close(); + } + } + + } + + NotificationGroup.fields = { + date: attr(), + id: attr(), + notification_type: attr(), + notifications: one2many('mail.notification'), + res_id: attr(), + res_model: attr(), + res_model_name: attr(), + /** + * Related thread when the notification group concerns a single thread. + */ + thread: many2one('mail.thread', { + compute: '_computeThread', + dependencies: [ + 'res_id', + 'res_model', + ], + }) + }; + + NotificationGroup.modelName = 'mail.notification_group'; + + return NotificationGroup; +} + +registerNewModel('mail.notification_group', factory); + +}); diff --git a/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js new file mode 100644 index 00000000..9c7c38ef --- /dev/null +++ b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js @@ -0,0 +1,77 @@ +odoo.define('mail/static/src/models/notification_group_manager/notification_group_manager.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class NotificationGroupManager extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + computeGroups() { + for (const group of this.groups) { + group.delete(); + } + const groups = []; + // TODO batch insert, better logic task-2258605 + this.env.messaging.currentPartner.failureNotifications.forEach(notification => { + const thread = notification.message.originThread; + // Notifications are grouped by model and notification_type. + // Except for channel where they are also grouped by id because + // we want to open the actual channel in discuss or chat window + // and not its kanban/list/form view. + const channelId = thread.model === 'mail.channel' ? thread.id : null; + const id = `${thread.model}/${channelId}/${notification.notification_type}`; + const group = this.env.models['mail.notification_group'].insert({ + id, + notification_type: notification.notification_type, + res_model: thread.model, + res_model_name: thread.model_name, + }); + group.update({ notifications: [['link', notification]] }); + // keep res_id only if all notifications are for the same record + // set null if multiple records are present in the group + let res_id = group.res_id; + if (group.res_id === undefined) { + res_id = thread.id; + } else if (group.res_id !== thread.id) { + res_id = null; + } + // keep only the most recent date from all notification messages + let date = group.date; + if (!date) { + date = notification.message.date; + } else { + date = moment.max(group.date, notification.message.date); + } + group.update({ + date, + res_id, + }); + // avoid linking the same group twice when adding a notification + // to an existing group + if (!groups.includes(group)) { + groups.push(group); + } + }); + this.update({ groups: [['link', groups]] }); + } + + } + + NotificationGroupManager.fields = { + groups: one2many('mail.notification_group'), + }; + + NotificationGroupManager.modelName = 'mail.notification_group_manager'; + + return NotificationGroupManager; +} + +registerNewModel('mail.notification_group_manager', factory); + +}); diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js new file mode 100644 index 00000000..4d007fb1 --- /dev/null +++ b/addons/mail/static/src/models/partner/partner.js @@ -0,0 +1,527 @@ +odoo.define('mail/static/src/models/partner/partner.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); +const { cleanSearchTerm } = require('mail/static/src/utils/utils.js'); + +function factory(dependencies) { + + class Partner extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @private + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('active' in data) { + data2.active = data.active; + } + if ('country' in data) { + if (!data.country) { + data2.country = [['unlink-all']]; + } else { + data2.country = [['insert', { + id: data.country[0], + name: data.country[1], + }]]; + } + } + if ('display_name' in data) { + data2.display_name = data.display_name; + } + if ('email' in data) { + data2.email = data.email; + } + if ('id' in data) { + data2.id = data.id; + } + if ('im_status' in data) { + data2.im_status = data.im_status; + } + if ('name' in data) { + data2.name = data.name; + } + + // relation + if ('user_id' in data) { + if (!data.user_id) { + data2.user = [['unlink-all']]; + } else { + let user = {}; + if (Array.isArray(data.user_id)) { + user = { + id: data.user_id[0], + display_name: data.user_id[1], + }; + } else { + user = { + id: data.user_id, + }; + } + user.isInternalUser = data.is_internal_user; + data2.user = [['insert', user]]; + } + } + + return data2; + } + + /** + * Fetches partners matching the given search term to extend the + * JS knowledge and to update the suggestion list accordingly. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize and/or restrict + * result in the context of given thread + */ + static async fetchSuggestions(searchTerm, { thread } = {}) { + const kwargs = { search: searchTerm }; + const isNonPublicChannel = thread && thread.model === 'mail.channel' && thread.public !== 'public'; + if (isNonPublicChannel) { + kwargs.channel_id = thread.id; + } + const [ + mainSuggestedPartners, + extraSuggestedPartners, + ] = await this.env.services.rpc( + { + model: 'res.partner', + method: 'get_mention_suggestions', + kwargs, + }, + { shadow: true }, + ); + const partnersData = mainSuggestedPartners.concat(extraSuggestedPartners); + const partners = this.env.models['mail.partner'].insert(partnersData.map(data => + this.env.models['mail.partner'].convertData(data) + )); + if (isNonPublicChannel) { + thread.update({ members: [['link', partners]] }); + } + } + + /** + * Search for partners matching `keyword`. + * + * @static + * @param {Object} param0 + * @param {function} param0.callback + * @param {string} param0.keyword + * @param {integer} [param0.limit=10] + */ + static async imSearch({ callback, keyword, limit = 10 }) { + // prefetched partners + let partners = []; + const cleanedSearchTerm = cleanSearchTerm(keyword); + const currentPartner = this.env.messaging.currentPartner; + for (const partner of this.all(partner => partner.active)) { + if (partners.length < limit) { + if ( + partner !== currentPartner && + partner.name && + partner.user && + cleanSearchTerm(partner.name).includes(cleanedSearchTerm) + ) { + partners.push(partner); + } + } + } + if (!partners.length) { + const partnersData = await this.env.services.rpc( + { + model: 'res.partner', + method: 'im_search', + args: [keyword, limit] + }, + { shadow: true } + ); + const newPartners = this.insert(partnersData.map( + partnerData => this.convertData(partnerData) + )); + partners.push(...newPartners); + } + callback(partners); + } + + /** + * Returns partners that match the given search term. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize and/or restrict + * result in the context of given thread + * @returns {[mail.partner[], mail.partner[]]} + */ + static searchSuggestions(searchTerm, { thread } = {}) { + let partners; + const isNonPublicChannel = thread && thread.model === 'mail.channel' && thread.public !== 'public'; + if (isNonPublicChannel) { + // Only return the channel members when in the context of a + // non-public channel. Indeed, the message with the mention + // would be notified to the mentioned partner, so this prevents + // from inadvertently leaking the private message to the + // mentioned partner. + partners = thread.members; + } else { + partners = this.env.models['mail.partner'].all(); + } + const cleanedSearchTerm = cleanSearchTerm(searchTerm); + const mainSuggestionList = []; + const extraSuggestionList = []; + for (const partner of partners) { + if ( + (!partner.active && partner !== this.env.messaging.partnerRoot) || + partner.id <= 0 || + this.env.messaging.publicPartners.includes(partner) + ) { + // ignore archived partners (except OdooBot), temporary + // partners (livechat guests), public partners (technical) + continue; + } + if ( + (partner.nameOrDisplayName && cleanSearchTerm(partner.nameOrDisplayName).includes(cleanedSearchTerm)) || + (partner.email && cleanSearchTerm(partner.email).includes(cleanedSearchTerm)) + ) { + if (partner.user) { + mainSuggestionList.push(partner); + } else { + extraSuggestionList.push(partner); + } + } + } + return [mainSuggestionList, extraSuggestionList]; + } + + /** + * @static + */ + static async startLoopFetchImStatus() { + await this._fetchImStatus(); + this._loopFetchImStatus(); + } + + /** + * Checks whether this partner has a related user and links them if + * applicable. + */ + async checkIsUser() { + const userIds = await this.async(() => this.env.services.rpc({ + model: 'res.users', + method: 'search', + args: [[['partner_id', '=', this.id]]], + kwargs: { + context: { active_test: false }, + }, + }, { shadow: true })); + this.update({ hasCheckedUser: true }); + if (userIds.length > 0) { + this.update({ user: [['insert', { id: userIds[0] }]] }); + } + } + + /** + * Gets the chat between the user of this partner and the current user. + * + * If a chat is not appropriate, a notification is displayed instead. + * + * @returns {mail.thread|undefined} + */ + async getChat() { + if (!this.user && !this.hasCheckedUser) { + await this.async(() => this.checkIsUser()); + } + // prevent chatting with non-users + if (!this.user) { + this.env.services['notification'].notify({ + message: this.env._t("You can only chat with partners that have a dedicated user."), + type: 'info', + }); + return; + } + return this.user.getChat(); + } + + /** + * Returns the text that identifies this partner in a mention. + * + * @returns {string} + */ + getMentionText() { + return this.name; + } + + /** + * Returns a sort function to determine the order of display of partners + * in the suggestion list. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize result in the + * context of given thread + * @returns {function} + */ + static getSuggestionSortFunction(searchTerm, { thread } = {}) { + const cleanedSearchTerm = cleanSearchTerm(searchTerm); + return (a, b) => { + const isAInternalUser = a.user && a.user.isInternalUser; + const isBInternalUser = b.user && b.user.isInternalUser; + if (isAInternalUser && !isBInternalUser) { + return -1; + } + if (!isAInternalUser && isBInternalUser) { + return 1; + } + if (thread && thread.model === 'mail.channel') { + const isAMember = thread.members.includes(a); + const isBMember = thread.members.includes(b); + if (isAMember && !isBMember) { + return -1; + } + if (!isAMember && isBMember) { + return 1; + } + } + if (thread) { + const isAFollower = thread.followersPartner.includes(a); + const isBFollower = thread.followersPartner.includes(b); + if (isAFollower && !isBFollower) { + return -1; + } + if (!isAFollower && isBFollower) { + return 1; + } + } + const cleanedAName = cleanSearchTerm(a.nameOrDisplayName || ''); + const cleanedBName = cleanSearchTerm(b.nameOrDisplayName || ''); + if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) { + return -1; + } + if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) { + return 1; + } + if (cleanedAName < cleanedBName) { + return -1; + } + if (cleanedAName > cleanedBName) { + return 1; + } + const cleanedAEmail = cleanSearchTerm(a.email || ''); + const cleanedBEmail = cleanSearchTerm(b.email || ''); + if (cleanedAEmail.startsWith(cleanedSearchTerm) && !cleanedAEmail.startsWith(cleanedSearchTerm)) { + return -1; + } + if (!cleanedBEmail.startsWith(cleanedSearchTerm) && cleanedBEmail.startsWith(cleanedSearchTerm)) { + return 1; + } + if (cleanedAEmail < cleanedBEmail) { + return -1; + } + if (cleanedAEmail > cleanedBEmail) { + return 1; + } + return a.id - b.id; + }; + } + + /** + * Opens a chat between the user of this partner and the current user + * and returns it. + * + * If a chat is not appropriate, a notification is displayed instead. + * + * @param {Object} [options] forwarded to @see `mail.thread:open()` + * @returns {mail.thread|undefined} + */ + async openChat(options) { + const chat = await this.async(() => this.getChat()); + if (!chat) { + return; + } + await this.async(() => chat.open(options)); + return chat; + } + + /** + * Opens the most appropriate view that is a profile for this partner. + */ + async openProfile() { + return this.env.messaging.openDocument({ + id: this.id, + model: 'res.partner', + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string} + */ + _computeAvatarUrl() { + return `/web/image/res.partner/${this.id}/image_128`; + } + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @static + * @private + */ + static async _fetchImStatus() { + const partnerIds = []; + for (const partner of this.all()) { + if (partner.im_status !== 'im_partner' && partner.id > 0) { + partnerIds.push(partner.id); + } + } + if (partnerIds.length === 0) { + return; + } + const dataList = await this.env.services.rpc({ + route: '/longpolling/im_status', + params: { + partner_ids: partnerIds, + }, + }, { shadow: true }); + this.insert(dataList); + } + + /** + * @static + * @private + */ + static _loopFetchImStatus() { + setTimeout(async () => { + await this._fetchImStatus(); + this._loopFetchImStatus(); + }, 50 * 1000); + } + + /** + * @private + * @returns {string|undefined} + */ + _computeDisplayName() { + return this.display_name || this.user && this.user.display_name; + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * @private + * @returns {string|undefined} + */ + _computeNameOrDisplayName() { + return this.name || this.display_name; + } + + } + + Partner.fields = { + active: attr({ + default: true, + }), + avatarUrl: attr({ + compute: '_computeAvatarUrl', + dependencies: [ + 'id', + ], + }), + correspondentThreads: one2many('mail.thread', { + inverse: 'correspondent', + }), + country: many2one('mail.country'), + display_name: attr({ + compute: '_computeDisplayName', + default: "", + dependencies: [ + 'display_name', + 'userDisplayName', + ], + }), + email: attr(), + failureNotifications: one2many('mail.notification', { + related: 'messagesAsAuthor.failureNotifications', + }), + /** + * Whether an attempt was already made to fetch the user corresponding + * to this partner. This prevents doing the same RPC multiple times. + */ + hasCheckedUser: attr({ + default: false, + }), + id: attr(), + im_status: attr(), + memberThreads: many2many('mail.thread', { + inverse: 'members', + }), + messagesAsAuthor: one2many('mail.message', { + inverse: 'author', + }), + /** + * Serves as compute dependency. + */ + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + model: attr({ + default: 'res.partner', + }), + /** + * Channels that are moderated by this partner. + */ + moderatedChannels: many2many('mail.thread', { + inverse: 'moderators', + }), + name: attr(), + nameOrDisplayName: attr({ + compute: '_computeNameOrDisplayName', + dependencies: [ + 'display_name', + 'name', + ], + }), + user: one2one('mail.user', { + inverse: 'partner', + }), + /** + * Serves as compute dependency. + */ + userDisplayName: attr({ + related: 'user.display_name', + }), + }; + + Partner.modelName = 'mail.partner'; + + return Partner; +} + +registerNewModel('mail.partner', factory); + +}); diff --git a/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js b/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js new file mode 100644 index 00000000..c8f12856 --- /dev/null +++ b/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js @@ -0,0 +1,116 @@ +odoo.define('mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class SuggestedRecipientInfo extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string} + */ + _computeEmail() { + return this.partner && this.partner.email || this.email; + } + + /** + * Prevents selecting a recipient that does not have a partner. + * + * @private + * @returns {boolean} + */ + _computeIsSelected() { + return this.partner ? this.isSelected : false; + } + + /** + * @private + * @returns {string} + */ + _computeName() { + return this.partner && this.partner.nameOrDisplayName || this.name; + } + + } + + SuggestedRecipientInfo.fields = { + /** + * Determines the email of `this`. It serves as visual clue when + * displaying `this`, and also serves as default partner email when + * creating a new partner from `this`. + */ + email: attr({ + compute: '_computeEmail', + dependencies: [ + 'email', + 'partnerEmail', + ], + }), + /** + * Determines whether `this` will be added to recipients when posting a + * new message on `this.thread`. + */ + isSelected: attr({ + compute: '_computeIsSelected', + default: true, + dependencies: [ + 'isSelected', + 'partner', + ], + }), + /** + * Determines the name of `this`. It serves as visual clue when + * displaying `this`, and also serves as default partner name when + * creating a new partner from `this`. + */ + name: attr({ + compute: '_computeName', + dependencies: [ + 'name', + 'partnerNameOrDisplayName', + ], + }), + /** + * Determines the optional `mail.partner` associated to `this`. + */ + partner: many2one('mail.partner'), + /** + * Serves as compute dependency. + */ + partnerEmail: attr({ + related: 'partner.email' + }), + /** + * Serves as compute dependency. + */ + partnerNameOrDisplayName: attr({ + related: 'partner.nameOrDisplayName' + }), + /** + * Determines why `this` is a suggestion for `this.thread`. It serves as + * visual clue when displaying `this`. + */ + reason: attr(), + /** + * Determines the `mail.thread` concerned by `this.` + */ + thread: many2one('mail.thread', { + inverse: 'suggestedRecipientInfoList', + }), + }; + + SuggestedRecipientInfo.modelName = 'mail.suggested_recipient_info'; + + return SuggestedRecipientInfo; +} + +registerNewModel('mail.suggested_recipient_info', factory); + +}); diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js new file mode 100644 index 00000000..1011eec4 --- /dev/null +++ b/addons/mail/static/src/models/thread/thread.js @@ -0,0 +1,2324 @@ +odoo.define('mail/static/src/models/thread/thread.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); +const throttle = require('mail/static/src/utils/throttle/throttle.js'); +const Timer = require('mail/static/src/utils/timer/timer.js'); +const { cleanSearchTerm } = require('mail/static/src/utils/utils.js'); +const mailUtils = require('mail.utils'); + +function factory(dependencies) { + + class Thread extends dependencies['mail.model'] { + + /** + * @override + */ + _willCreate() { + const res = super._willCreate(...arguments); + /** + * Timer of current partner that was currently typing something, but + * there is no change on the input for 5 seconds. This is used + * in order to automatically notify other members that current + * partner has stopped typing something, due to making no changes + * on the composer for some time. + */ + this._currentPartnerInactiveTypingTimer = new Timer( + this.env, + () => this.async(() => this._onCurrentPartnerInactiveTypingTimeout()), + 5 * 1000 + ); + /** + * Last 'is_typing' status of current partner that has been notified + * to other members. Useful to prevent spamming typing notifications + * to other members if it hasn't changed. An exception is the + * current partner long typing scenario where current partner has + * to re-send the same typing notification from time to time, so + * that other members do not assume he/she is no longer typing + * something from not receiving any typing notifications for a + * very long time. + * + * Supported values: true/false/undefined. + * undefined makes only sense initially and during current partner + * long typing timeout flow. + */ + this._currentPartnerLastNotifiedIsTyping = undefined; + /** + * Timer of current partner that is typing a very long text. When + * the other members do not receive any typing notification for a + * long time, they must assume that the related partner is no longer + * typing something (e.g. they have closed the browser tab). + * This is a timer to let other members know that current partner + * is still typing something, so that they should not assume he/she + * has stopped typing something. + */ + this._currentPartnerLongTypingTimer = new Timer( + this.env, + () => this.async(() => this._onCurrentPartnerLongTypingTimeout()), + 50 * 1000 + ); + /** + * Determines whether the next request to notify current partner + * typing status should always result to making RPC, regardless of + * whether last notified current partner typing status is the same. + * Most of the time we do not want to notify if value hasn't + * changed, exception being the long typing scenario of current + * partner. + */ + this._forceNotifyNextCurrentPartnerTypingStatus = false; + /** + * Registry of timers of partners currently typing in the thread, + * excluding current partner. This is useful in order to + * automatically unregister typing members when not receive any + * typing notification after a long time. Timers are internally + * indexed by partner records as key. The current partner is + * ignored in this registry of timers. + * + * @see registerOtherMemberTypingMember + * @see unregisterOtherMemberTypingMember + */ + this._otherMembersLongTypingTimers = new Map(); + + /** + * Clearable and cancellable throttled version of the + * `_notifyCurrentPartnerTypingStatus` method. + * This is useful when the current partner posts a message and + * types something else afterwards: it must notify immediately that + * he/she is typing something, instead of waiting for the throttle + * internal timer. + * + * @see _notifyCurrentPartnerTypingStatus + */ + this._throttleNotifyCurrentPartnerTypingStatus = throttle( + this.env, + ({ isTyping }) => this.async(() => this._notifyCurrentPartnerTypingStatus({ isTyping })), + 2.5 * 1000 + ); + return res; + } + + /** + * @override + */ + _willDelete() { + this._currentPartnerInactiveTypingTimer.clear(); + this._currentPartnerLongTypingTimer.clear(); + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + for (const timer of this._otherMembersLongTypingTimers.values()) { + timer.clear(); + } + if (this.isTemporary) { + for (const message of this.messages) { + message.delete(); + } + } + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {mail.thread} [thread] the concerned thread + */ + static computeLastCurrentPartnerMessageSeenByEveryone(thread = undefined) { + const threads = thread ? [thread] : this.env.models['mail.thread'].all(); + threads.map(localThread => { + localThread.update({ + lastCurrentPartnerMessageSeenByEveryone: localThread._computeLastCurrentPartnerMessageSeenByEveryone(), + }); + }); + } + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = { + messagesAsServerChannel: [], + }; + if ('model' in data) { + data2.model = data.model; + } + if ('channel_type' in data) { + data2.channel_type = data.channel_type; + data2.model = 'mail.channel'; + } + if ('create_uid' in data) { + data2.creator = [['insert', { id: data.create_uid }]]; + } + if ('custom_channel_name' in data) { + data2.custom_channel_name = data.custom_channel_name; + } + if ('group_based_subscription' in data) { + data2.group_based_subscription = data.group_based_subscription; + } + if ('id' in data) { + data2.id = data.id; + } + if ('is_minimized' in data && 'state' in data) { + data2.serverFoldState = data.is_minimized ? data.state : 'closed'; + } + if ('is_moderator' in data) { + data2.is_moderator = data.is_moderator; + } + if ('is_pinned' in data) { + data2.isServerPinned = data.is_pinned; + } + if ('last_message' in data && data.last_message) { + data2.messagesAsServerChannel.push(['insert', { id: data.last_message.id }]); + data2.serverLastMessageId = data.last_message.id; + } + if ('last_message_id' in data && data.last_message_id) { + data2.messagesAsServerChannel.push(['insert', { id: data.last_message_id }]); + data2.serverLastMessageId = data.last_message_id; + } + if ('mass_mailing' in data) { + data2.mass_mailing = data.mass_mailing; + } + if ('moderation' in data) { + data2.moderation = data.moderation; + } + if ('message_needaction_counter' in data) { + data2.message_needaction_counter = data.message_needaction_counter; + } + if ('message_unread_counter' in data) { + data2.serverMessageUnreadCounter = data.message_unread_counter; + } + if ('name' in data) { + data2.name = data.name; + } + if ('public' in data) { + data2.public = data.public; + } + if ('seen_message_id' in data) { + data2.lastSeenByCurrentPartnerMessageId = data.seen_message_id || 0; + } + if ('uuid' in data) { + data2.uuid = data.uuid; + } + + // relations + if ('members' in data) { + if (!data.members) { + data2.members = [['unlink-all']]; + } else { + data2.members = [ + ['insert-and-replace', data.members.map(memberData => + this.env.models['mail.partner'].convertData(memberData) + )], + ]; + } + } + if ('seen_partners_info' in data) { + if (!data.seen_partners_info) { + data2.partnerSeenInfos = [['unlink-all']]; + } else { + /* + * FIXME: not optimal to write on relation given the fact that the relation + * will be (re)computed based on given fields. + * (here channelId will compute partnerSeenInfo.thread)) + * task-2336946 + */ + data2.partnerSeenInfos = [ + ['insert-and-replace', + data.seen_partners_info.map( + ({ fetched_message_id, partner_id, seen_message_id }) => { + return { + channelId: data2.id, + lastFetchedMessage: [fetched_message_id ? ['insert', { id: fetched_message_id }] : ['unlink-all']], + lastSeenMessage: [seen_message_id ? ['insert', { id: seen_message_id }] : ['unlink-all']], + partnerId: partner_id, + }; + }) + ] + ]; + if (data.id || this.id) { + const messageIds = data.seen_partners_info.reduce((currentSet, { fetched_message_id, seen_message_id }) => { + if (fetched_message_id) { + currentSet.add(fetched_message_id); + } + if (seen_message_id) { + currentSet.add(seen_message_id); + } + return currentSet; + }, new Set()); + if (messageIds.size > 0) { + /* + * FIXME: not optimal to write on relation given the fact that the relation + * will be (re)computed based on given fields. + * (here channelId will compute messageSeenIndicator.thread)) + * task-2336946 + */ + data2.messageSeenIndicators = [ + ['insert', + [...messageIds].map(messageId => { + return { + channelId: data.id || this.id, + messageId, + }; + }) + ] + ]; + } + } + } + } + + return data2; + } + + /** + * Fetches threads matching the given composer search state to extend + * the JS knowledge and to update the suggestion list accordingly. + * More specifically only thread of model 'mail.channel' are fetched. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize and/or restrict + * result in the context of given thread + */ + static async fetchSuggestions(searchTerm, { thread } = {}) { + const channelsData = await this.env.services.rpc( + { + model: 'mail.channel', + method: 'get_mention_suggestions', + kwargs: { search: searchTerm }, + }, + { shadow: true }, + ); + this.env.models['mail.thread'].insert(channelsData.map(channelData => + Object.assign( + { model: 'mail.channel' }, + this.env.models['mail.thread'].convertData(channelData), + ) + )); + } + + /** + * Returns a sort function to determine the order of display of threads + * in the suggestion list. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize result in the + * context of given thread + * @returns {function} + */ + static getSuggestionSortFunction(searchTerm, { thread } = {}) { + const cleanedSearchTerm = cleanSearchTerm(searchTerm); + return (a, b) => { + const isAPublic = a.model === 'mail.channel' && a.public === 'public'; + const isBPublic = b.model === 'mail.channel' && b.public === 'public'; + if (isAPublic && !isBPublic) { + return -1; + } + if (!isAPublic && isBPublic) { + return 1; + } + const isMemberOfA = a.model === 'mail.channel' && a.members.includes(this.env.messaging.currentPartner); + const isMemberOfB = b.model === 'mail.channel' && b.members.includes(this.env.messaging.currentPartner); + if (isMemberOfA && !isMemberOfB) { + return -1; + } + if (!isMemberOfA && isMemberOfB) { + return 1; + } + const cleanedAName = cleanSearchTerm(a.name || ''); + const cleanedBName = cleanSearchTerm(b.name || ''); + if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) { + return -1; + } + if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) { + return 1; + } + if (cleanedAName < cleanedBName) { + return -1; + } + if (cleanedAName > cleanedBName) { + return 1; + } + return a.id - b.id; + }; + } + + /** + * Load the previews of the specified threads. Basically, it fetches the + * last messages, since they are used to display inline content of them. + * + * @static + * @param {mail.thread[]} threads + */ + static async loadPreviews(threads) { + const channelIds = threads.reduce((list, thread) => { + if (thread.model === 'mail.channel') { + return list.concat(thread.id); + } + return list; + }, []); + if (channelIds.length === 0) { + return; + } + const channelPreviews = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fetch_preview', + args: [channelIds], + }, { shadow: true }); + this.env.models['mail.message'].insert(channelPreviews.filter(p => p.last_message).map( + channelPreview => this.env.models['mail.message'].convertData(channelPreview.last_message) + )); + } + + + /** + * Performs the `channel_fold` RPC on `mail.channel`. + * + * @static + * @param {string} uuid + * @param {string} state + */ + static async performRpcChannelFold(uuid, state) { + return this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fold', + kwargs: { + state, + uuid, + } + }, { shadow: true }); + } + + /** + * Performs the `channel_info` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {integer[]} param0.ids list of id of channels + * @returns {mail.thread[]} + */ + static async performRpcChannelInfo({ ids }) { + const channelInfos = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_info', + args: [ids], + }, { shadow: true }); + const channels = this.env.models['mail.thread'].insert( + channelInfos.map(channelInfo => this.env.models['mail.thread'].convertData(channelInfo)) + ); + // manually force recompute of counter + this.env.messaging.messagingMenu.update(); + return channels; + } + + /** + * Performs the `channel_seen` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {integer[]} param0.ids list of id of channels + * @param {integer[]} param0.lastMessageId + */ + static async performRpcChannelSeen({ ids, lastMessageId }) { + return this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_seen', + args: [ids], + kwargs: { + last_message_id: lastMessageId, + }, + }, { shadow: true }); + } + + /** + * Performs the `channel_pin` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {boolean} [param0.pinned=false] + * @param {string} param0.uuid + */ + static async performRpcChannelPin({ pinned = false, uuid }) { + return this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_pin', + kwargs: { + uuid, + pinned, + }, + }, { shadow: true }); + } + + /** + * Performs the `channel_create` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {string} param0.name + * @param {string} [param0.privacy] + * @returns {mail.thread} the created channel + */ + static async performRpcCreateChannel({ name, privacy }) { + const device = this.env.messaging.device; + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_create', + args: [name, privacy], + kwargs: { + context: Object.assign({}, this.env.session.user_content, { + // optimize the return value by avoiding useless queries + // in non-mobile devices + isMobile: device.isMobile, + }), + }, + }); + return this.env.models['mail.thread'].insert( + this.env.models['mail.thread'].convertData(data) + ); + } + + /** + * Performs the `channel_get` RPC on `mail.channel`. + * + * `openChat` is preferable in business code because it will avoid the + * RPC if the chat already exists. + * + * @static + * @param {Object} param0 + * @param {integer[]} param0.partnerIds + * @param {boolean} [param0.pinForCurrentPartner] + * @returns {mail.thread|undefined} the created or existing chat + */ + static async performRpcCreateChat({ partnerIds, pinForCurrentPartner }) { + const device = this.env.messaging.device; + // TODO FIX: potential duplicate chat task-2276490 + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_get', + kwargs: { + context: Object.assign({}, this.env.session.user_content, { + // optimize the return value by avoiding useless queries + // in non-mobile devices + isMobile: device.isMobile, + }), + partners_to: partnerIds, + pin: pinForCurrentPartner, + }, + }); + if (!data) { + return; + } + return this.env.models['mail.thread'].insert( + this.env.models['mail.thread'].convertData(data) + ); + } + + /** + * Performs the `channel_join_and_get_info` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {integer} param0.channelId + * @returns {mail.thread} the channel that was joined + */ + static async performRpcJoinChannel({ channelId }) { + const device = this.env.messaging.device; + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_join_and_get_info', + args: [[channelId]], + kwargs: { + context: Object.assign({}, this.env.session.user_content, { + // optimize the return value by avoiding useless queries + // in non-mobile devices + isMobile: device.isMobile, + }), + }, + }); + return this.env.models['mail.thread'].insert( + this.env.models['mail.thread'].convertData(data) + ); + } + + /** + * Performs the `execute_command` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {string} param0.command + * @param {Object} [param0.postData={}] + */ + static async performRpcExecuteCommand({ channelId, command, postData = {} }) { + return this.env.services.rpc({ + model: 'mail.channel', + method: 'execute_command', + args: [[channelId]], + kwargs: Object.assign({ command }, postData), + }); + } + + /** + * Performs the `message_post` RPC on given threadModel. + * + * @static + * @param {Object} param0 + * @param {Object} param0.postData + * @param {integer} param0.threadId + * @param {string} param0.threadModel + * @return {integer} the posted message id + */ + static async performRpcMessagePost({ postData, threadId, threadModel }) { + return this.env.services.rpc({ + model: threadModel, + method: 'message_post', + args: [threadId], + kwargs: postData, + }); + } + + /** + * Performs RPC on the route `/mail/get_suggested_recipients`. + * + * @static + * @param {Object} param0 + * @param {string} param0.model + * @param {integer[]} param0.res_id + */ + static async performRpcMailGetSuggestedRecipients({ model, res_ids }) { + const data = await this.env.services.rpc({ + route: '/mail/get_suggested_recipients', + params: { + model, + res_ids, + }, + }, { shadow: true }); + for (const id in data) { + const recipientInfoList = data[id].map(recipientInfoData => { + const [partner_id, emailInfo, reason] = recipientInfoData; + const [name, email] = emailInfo && mailUtils.parseEmail(emailInfo); + return { + email, + name, + partner: [partner_id ? ['insert', { id: partner_id }] : ['unlink']], + reason, + }; + }); + this.insert({ + id: parseInt(id), + model, + suggestedRecipientInfoList: [['insert-and-replace', recipientInfoList]], + }); + } + } + + /* + * Returns threads that match the given search term. More specially only + * threads of model 'mail.channel' are suggested, and if the context + * thread is a private channel, only itself is returned if it matches + * the search term. + * + * @static + * @param {string} searchTerm + * @param {Object} [options={}] + * @param {mail.thread} [options.thread] prioritize and/or restrict + * result in the context of given thread + * @returns {[mail.threads[], mail.threads[]]} + */ + static searchSuggestions(searchTerm, { thread } = {}) { + let threads; + if (thread && thread.model === 'mail.channel' && thread.public !== 'public') { + // Only return the current channel when in the context of a + // non-public channel. Indeed, the message with the mention + // would appear in the target channel, so this prevents from + // inadvertently leaking the private message into the mentioned + // channel. + threads = [thread]; + } else { + threads = this.env.models['mail.thread'].all(); + } + const cleanedSearchTerm = cleanSearchTerm(searchTerm); + return [threads.filter(thread => + !thread.isTemporary && + thread.model === 'mail.channel' && + thread.channel_type === 'channel' && + thread.name && + cleanSearchTerm(thread.name).includes(cleanedSearchTerm) + )]; + } + + /** + * @param {string} [stringifiedDomain='[]'] + * @returns {mail.thread_cache} + */ + cache(stringifiedDomain = '[]') { + return this.env.models['mail.thread_cache'].insert({ + stringifiedDomain, + thread: [['link', this]], + }); + } + + /** + * Fetch attachments linked to a record. Useful for populating the store + * with these attachments, which are used by attachment box in the chatter. + */ + async fetchAttachments() { + const attachmentsData = await this.async(() => this.env.services.rpc({ + model: 'ir.attachment', + method: 'search_read', + domain: [ + ['res_id', '=', this.id], + ['res_model', '=', this.model], + ], + fields: ['id', 'name', 'mimetype'], + orderBy: [{ name: 'id', asc: false }], + }, { shadow: true })); + this.update({ + originThreadAttachments: [['insert-and-replace', + attachmentsData.map(data => + this.env.models['mail.attachment'].convertData(data) + ) + ]], + }); + this.update({ areAttachmentsLoaded: true }); + } + + /** + * Fetches suggested recipients. + */ + async fetchAndUpdateSuggestedRecipients() { + if (this.isTemporary) { + return; + } + return this.env.models['mail.thread'].performRpcMailGetSuggestedRecipients({ + model: this.model, + res_ids: [this.id], + }); + } + + /** + * Add current user to provided thread's followers. + */ + async follow() { + await this.async(() => this.env.services.rpc({ + model: this.model, + method: 'message_subscribe', + args: [[this.id]], + kwargs: { + partner_ids: [this.env.messaging.currentPartner.id], + context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187 + }, + })); + this.refreshFollowers(); + this.fetchAndUpdateSuggestedRecipients(); + } + + /** + * Returns the text that identifies this thread in a mention. + * + * @returns {string} + */ + getMentionText() { + return this.name; + } + + /** + * Load new messages on the main cache of this thread. + */ + loadNewMessages() { + this.mainCache.loadNewMessages(); + } + + /** + * Mark the specified conversation as fetched. + */ + async markAsFetched() { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fetched', + args: [[this.id]], + }, { shadow: true })); + } + + /** + * Mark the specified conversation as read/seen. + * + * @param {mail.message} message the message to be considered as last seen. + */ + async markAsSeen(message) { + if (this.model !== 'mail.channel') { + return; + } + if (this.pendingSeenMessageId && message.id <= this.pendingSeenMessageId) { + return; + } + if ( + this.lastSeenByCurrentPartnerMessageId && + message.id <= this.lastSeenByCurrentPartnerMessageId + ) { + return; + } + this.update({ pendingSeenMessageId: message.id }); + return this.env.models['mail.thread'].performRpcChannelSeen({ + ids: [this.id], + lastMessageId: message.id, + }); + } + + /** + * Marks as read all needaction messages with this thread as origin. + */ + async markNeedactionMessagesAsOriginThreadAsRead() { + await this.async(() => + this.env.models['mail.message'].markAsRead(this.needactionMessagesAsOriginThread) + ); + } + + /** + * Mark as read all needaction messages of this thread. + */ + async markNeedactionMessagesAsRead() { + await this.async(() => + this.env.models['mail.message'].markAsRead(this.needactionMessages) + ); + } + + /** + * Notifies the server of new fold state. Useful for initial, + * cross-tab, and cross-device chat window state synchronization. + * + * @param {string} state + */ + async notifyFoldStateToServer(state) { + if (this.model !== 'mail.channel') { + // Server sync of fold state is only supported for channels. + return; + } + if (!this.uuid) { + return; + } + return this.env.models['mail.thread'].performRpcChannelFold(this.uuid, state); + } + + /** + * Notify server to leave the current channel. Useful for cross-tab + * and cross-device chat window state synchronization. + * + * Only makes sense if isPendingPinned is set to the desired value. + */ + async notifyPinStateToServer() { + if (this.isPendingPinned) { + await this.env.models['mail.thread'].performRpcChannelPin({ + pinned: true, + uuid: this.uuid, + }); + } else { + this.env.models['mail.thread'].performRpcExecuteCommand({ + channelId: this.id, + command: 'leave', + }); + } + } + + /** + * Opens this thread either as form view, in discuss app, or as a chat + * window. The thread will be opened in an "active" matter, which will + * interrupt current user flow. + * + * @param {Object} [param0] + * @param {boolean} [param0.expanded=false] + */ + async open({ expanded = false } = {}) { + const discuss = this.env.messaging.discuss; + // check if thread must be opened in form view + if (!['mail.box', 'mail.channel'].includes(this.model)) { + if (expanded || discuss.isOpen) { + // Close chat window because having the same thread opened + // both in chat window and as main document does not look + // good. + this.env.messaging.chatWindowManager.closeThread(this); + return this.env.messaging.openDocument({ + id: this.id, + model: this.model, + }); + } + } + // check if thread must be opened in discuss + const device = this.env.messaging.device; + if ( + (!device.isMobile && (discuss.isOpen || expanded)) || + this.model === 'mail.box' + ) { + return discuss.openThread(this); + } + // thread must be opened in chat window + return this.env.messaging.chatWindowManager.openThread(this, { + makeActive: true, + }); + } + + /** + * Opens the most appropriate view that is a profile for this thread. + */ + async openProfile() { + return this.env.messaging.openDocument({ + id: this.id, + model: this.model, + }); + } + + /** + * Pin this thread and notify server of the change. + */ + async pin() { + this.update({ isPendingPinned: true }); + await this.notifyPinStateToServer(); + } + + /** + * Open a dialog to add channels as followers. + */ + promptAddChannelFollower() { + this._promptAddFollower({ mail_invite_follower_channel_only: true }); + } + + /** + * Open a dialog to add partners as followers. + */ + promptAddPartnerFollower() { + this._promptAddFollower({ mail_invite_follower_channel_only: false }); + } + + async refresh() { + if (this.isTemporary) { + return; + } + this.loadNewMessages(); + this.update({ isLoadingAttachments: true }); + await this.async(() => this.fetchAttachments()); + this.update({ isLoadingAttachments: false }); + } + + async refreshActivities() { + if (!this.hasActivities) { + return; + } + if (this.isTemporary) { + return; + } + // A bit "extreme", may be improved + const [{ activity_ids: newActivityIds }] = await this.async(() => this.env.services.rpc({ + model: this.model, + method: 'read', + args: [this.id, ['activity_ids']] + }, { shadow: true })); + const activitiesData = await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'activity_format', + args: [newActivityIds] + }, { shadow: true })); + const activities = this.env.models['mail.activity'].insert(activitiesData.map( + activityData => this.env.models['mail.activity'].convertData(activityData) + )); + this.update({ activities: [['replace', activities]] }); + } + + /** + * Refresh followers information from server. + */ + async refreshFollowers() { + if (this.isTemporary) { + this.update({ followers: [['unlink-all']] }); + return; + } + const { followers } = await this.async(() => this.env.services.rpc({ + route: '/mail/read_followers', + params: { + res_id: this.id, + res_model: this.model, + }, + }, { shadow: true })); + this.update({ areFollowersLoaded: true }); + if (followers.length > 0) { + this.update({ + followers: [['insert-and-replace', followers.map(data => + this.env.models['mail.follower'].convertData(data)) + ]], + }); + } else { + this.update({ + followers: [['unlink-all']], + }); + } + } + + /** + * Refresh the typing status of the current partner. + */ + refreshCurrentPartnerIsTyping() { + this._currentPartnerInactiveTypingTimer.reset(); + } + + /** + * Called to refresh a registered other member partner that is typing + * something. + * + * @param {mail.partner} partner + */ + refreshOtherMemberTypingMember(partner) { + this._otherMembersLongTypingTimers.get(partner).reset(); + } + + /** + * Called when current partner is inserting some input in composer. + * Useful to notify current partner is currently typing something in the + * composer of this thread to all other members. + */ + async registerCurrentPartnerIsTyping() { + // Handling of typing timers. + this._currentPartnerInactiveTypingTimer.start(); + this._currentPartnerLongTypingTimer.start(); + // Manage typing member relation. + const currentPartner = this.env.messaging.currentPartner; + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== currentPartner.localId); + newOrderedTypingMemberLocalIds.push(currentPartner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['link', currentPartner]], + }); + // Notify typing status to other members. + await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true }); + } + + /** + * Called to register a new other member partner that is typing + * something. + * + * @param {mail.partner} partner + */ + registerOtherMemberTypingMember(partner) { + const timer = new Timer( + this.env, + () => this.async(() => this._onOtherMemberLongTypingTimeout(partner)), + 60 * 1000 + ); + this._otherMembersLongTypingTimers.set(partner, timer); + timer.start(); + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== partner.localId); + newOrderedTypingMemberLocalIds.push(partner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['link', partner]], + }); + } + + /** + * Rename the given thread with provided new name. + * + * @param {string} newName + */ + async rename(newName) { + if (this.channel_type === 'chat') { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_set_custom_name', + args: [this.id], + kwargs: { + name: newName, + }, + })); + } + this.update({ custom_channel_name: newName }); + } + + /** + * Unfollow current partner from this thread. + */ + async unfollow() { + const currentPartnerFollower = this.followers.find( + follower => follower.partner === this.env.messaging.currentPartner + ); + await this.async(() => currentPartnerFollower.remove()); + } + + /** + * Unpin this thread and notify server of the change. + */ + async unpin() { + this.update({ isPendingPinned: false }); + await this.notifyPinStateToServer(); + } + + /** + * Called when current partner has explicitly stopped inserting some + * input in composer. Useful to notify current partner has currently + * stopped typing something in the composer of this thread to all other + * members. + * + * @param {Object} [param0={}] + * @param {boolean} [param0.immediateNotify=false] if set, is typing + * status of current partner is immediately notified and doesn't + * consume throttling at all. + */ + async unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) { + // Handling of typing timers. + this._currentPartnerInactiveTypingTimer.clear(); + this._currentPartnerLongTypingTimer.clear(); + // Manage typing member relation. + const currentPartner = this.env.messaging.currentPartner; + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== currentPartner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['unlink', currentPartner]], + }); + // Notify typing status to other members. + if (immediateNotify) { + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + } + await this.async( + () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: false }) + ); + } + + /** + * Called to unregister an other member partner that is no longer typing + * something. + * + * @param {mail.partner} partner + */ + unregisterOtherMemberTypingMember(partner) { + this._otherMembersLongTypingTimers.get(partner).clear(); + this._otherMembersLongTypingTimers.delete(partner); + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== partner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['unlink', partner]], + }); + } + + /** + * Unsubscribe current user from provided channel. + */ + unsubscribe() { + this.env.messaging.chatWindowManager.closeThread(this); + this.unpin(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + const { channel_type, id, model } = data; + let threadModel = model; + if (!threadModel && channel_type) { + threadModel = 'mail.channel'; + } + return `${this.modelName}_${threadModel}_${id}`; + } + + /** + * @private + * @returns {mail.attachment[]} + */ + _computeAllAttachments() { + const allAttachments = [...new Set(this.originThreadAttachments.concat(this.attachments))] + .sort((a1, a2) => { + // "uploading" before "uploaded" attachments. + if (!a1.isTemporary && a2.isTemporary) { + return 1; + } + if (a1.isTemporary && !a2.isTemporary) { + return -1; + } + // "most-recent" before "oldest" attachments. + return Math.abs(a2.id) - Math.abs(a1.id); + }); + return [['replace', allAttachments]]; + } + + /** + * @private + * @returns {mail.partner} + */ + _computeCorrespondent() { + if (this.channel_type === 'channel') { + return [['unlink']]; + } + const correspondents = this.members.filter(partner => + partner !== this.env.messaging.currentPartner + ); + if (correspondents.length === 1) { + // 2 members chat + return [['link', correspondents[0]]]; + } + if (this.members.length === 1) { + // chat with oneself + return [['link', this.members[0]]]; + } + return [['unlink']]; + } + + /** + * @private + * @returns {string} + */ + _computeDisplayName() { + if (this.channel_type === 'chat' && this.correspondent) { + return this.custom_channel_name || this.correspondent.nameOrDisplayName; + } + return this.name; + } + + /** + * @private + * @returns {mail.activity[]} + */ + _computeFutureActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'planned')]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasSeenIndicators() { + if (this.model !== 'mail.channel') { + return false; + } + if (this.mass_mailing) { + return false; + } + return ['chat', 'livechat'].includes(this.channel_type); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsChatChannel() { + return this.channel_type === 'chat'; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerFollowing() { + return this.followers.some(follower => + follower.partner && follower.partner === this.env.messaging.currentPartner + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsModeratedByCurrentPartner() { + if (!this.messaging) { + return false; + } + if (!this.messaging.currentPartner) { + return false; + } + return this.moderators.includes(this.env.messaging.currentPartner); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsPinned() { + return this.isPendingPinned !== undefined ? this.isPendingPinned : this.isServerPinned; + } + + /** + * @private + * @returns {mail.message} + */ + _computeLastCurrentPartnerMessageSeenByEveryone() { + const otherPartnerSeenInfos = + this.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.messagingCurrentPartner); + if (otherPartnerSeenInfos.length === 0) { + return [['unlink-all']]; + } + + const otherPartnersLastSeenMessageIds = + otherPartnerSeenInfos.map(partnerSeenInfo => + partnerSeenInfo.lastSeenMessage ? partnerSeenInfo.lastSeenMessage.id : 0 + ); + if (otherPartnersLastSeenMessageIds.length === 0) { + return [['unlink-all']]; + } + const lastMessageSeenByAllId = Math.min( + ...otherPartnersLastSeenMessageIds + ); + const currentPartnerOrderedSeenMessages = + this.orderedNonTransientMessages.filter(message => + message.author === this.messagingCurrentPartner && + message.id <= lastMessageSeenByAllId); + + if ( + !currentPartnerOrderedSeenMessages || + currentPartnerOrderedSeenMessages.length === 0 + ) { + return [['unlink-all']]; + } + return [['link', currentPartnerOrderedSeenMessages.slice().pop()]]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastMessage() { + const { + length: l, + [l - 1]: lastMessage, + } = this.orderedMessages; + if (lastMessage) { + return [['link', lastMessage]]; + } + return [['unlink']]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastNonTransientMessage() { + const { + length: l, + [l - 1]: lastMessage, + } = this.orderedNonTransientMessages; + if (lastMessage) { + return [['link', lastMessage]]; + } + return [['unlink']]; + } + + /** + * Adjusts the last seen message received from the server to consider + * the following messages also as read if they are either transient + * messages or messages from the current partner. + * + * @private + * @returns {integer} + */ + _computeLastSeenByCurrentPartnerMessageId() { + const firstMessage = this.orderedMessages[0]; + if ( + firstMessage && + this.lastSeenByCurrentPartnerMessageId && + this.lastSeenByCurrentPartnerMessageId < firstMessage.id + ) { + // no deduction can be made if there is a gap + return this.lastSeenByCurrentPartnerMessageId; + } + let lastSeenByCurrentPartnerMessageId = this.lastSeenByCurrentPartnerMessageId; + for (const message of this.orderedMessages) { + if (message.id <= this.lastSeenByCurrentPartnerMessageId) { + continue; + } + if ( + message.author === this.env.messaging.currentPartner || + message.isTransient + ) { + lastSeenByCurrentPartnerMessageId = message.id; + continue; + } + return lastSeenByCurrentPartnerMessageId; + } + return lastSeenByCurrentPartnerMessageId; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastNeedactionMessage() { + const orderedNeedactionMessages = this.needactionMessages.sort( + (m1, m2) => m1.id < m2.id ? -1 : 1 + ); + const { + length: l, + [l - 1]: lastNeedactionMessage, + } = orderedNeedactionMessages; + if (lastNeedactionMessage) { + return [['link', lastNeedactionMessage]]; + } + return [['unlink']]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastNeedactionMessageAsOriginThread() { + const orderedNeedactionMessagesAsOriginThread = this.needactionMessagesAsOriginThread.sort( + (m1, m2) => m1.id < m2.id ? -1 : 1 + ); + const { + length: l, + [l - 1]: lastNeedactionMessageAsOriginThread, + } = orderedNeedactionMessagesAsOriginThread; + if (lastNeedactionMessageAsOriginThread) { + return [['link', lastNeedactionMessageAsOriginThread]]; + } + return [['unlink']]; + } + + /** + * @private + * @returns {mail.thread_cache} + */ + _computeMainCache() { + return [['link', this.cache()]]; + } + + /** + * @private + * @returns {integer} + */ + _computeLocalMessageUnreadCounter() { + if (this.model !== 'mail.channel') { + // unread counter only makes sense on channels + return clear(); + } + // By default trust the server up to the last message it used + // because it's not possible to do better. + let baseCounter = this.serverMessageUnreadCounter; + let countFromId = this.serverLastMessageId; + // But if the client knows the last seen message that the server + // returned (and by assumption all the messages that come after), + // the counter can be computed fully locally, ignoring potentially + // obsolete values from the server. + const firstMessage = this.orderedMessages[0]; + if ( + firstMessage && + this.lastSeenByCurrentPartnerMessageId && + this.lastSeenByCurrentPartnerMessageId >= firstMessage.id + ) { + baseCounter = 0; + countFromId = this.lastSeenByCurrentPartnerMessageId; + } + // Include all the messages that are known locally but the server + // didn't take into account. + return this.orderedMessages.reduce((total, message) => { + if (message.id <= countFromId) { + return total; + } + return total + 1; + }, baseCounter); + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeNeedactionMessages() { + return [['replace', this.messages.filter(message => message.isNeedaction)]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeNeedactionMessagesAsOriginThread() { + return [['replace', this.messagesAsOriginThread.filter(message => message.isNeedaction)]]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeMessageAfterNewMessageSeparator() { + if (this.model !== 'mail.channel') { + return [['unlink']]; + } + if (this.localMessageUnreadCounter === 0) { + return [['unlink']]; + } + const index = this.orderedMessages.findIndex(message => + message.id === this.lastSeenByCurrentPartnerMessageId + ); + if (index === -1) { + return [['unlink']]; + } + const message = this.orderedMessages[index + 1]; + if (!message) { + return [['unlink']]; + } + return [['link', message]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeOrderedMessages() { + return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeOrderedNonTransientMessages() { + return [['replace', this.orderedMessages.filter(m => !m.isTransient)]]; + } + + /** + * @private + * @returns {mail.partner[]} + */ + _computeOrderedOtherTypingMembers() { + return [[ + 'replace', + this.orderedTypingMembers.filter( + member => member !== this.env.messaging.currentPartner + ), + ]]; + } + + /** + * @private + * @returns {mail.partner[]} + */ + _computeOrderedTypingMembers() { + return [[ + 'replace', + this.orderedTypingMemberLocalIds + .map(localId => this.env.models['mail.partner'].get(localId)) + .filter(member => !!member), + ]]; + } + + /** + * @private + * @returns {mail.activity[]} + */ + _computeOverdueActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'overdue')]]; + } + + /** + * @private + * @returns {mail.activity[]} + */ + _computeTodayActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'today')]]; + } + + /** + * @private + * @returns {string} + */ + _computeTypingStatusText() { + if (this.orderedOtherTypingMembers.length === 0) { + return this.constructor.fields.typingStatusText.default; + } + if (this.orderedOtherTypingMembers.length === 1) { + return _.str.sprintf( + this.env._t("%s is typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName + ); + } + if (this.orderedOtherTypingMembers.length === 2) { + return _.str.sprintf( + this.env._t("%s and %s are typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName, + this.orderedOtherTypingMembers[1].nameOrDisplayName + ); + } + return _.str.sprintf( + this.env._t("%s, %s and more are typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName, + this.orderedOtherTypingMembers[1].nameOrDisplayName + ); + } + + /** + * Compute an url string that can be used inside a href attribute + * + * @private + * @returns {string} + */ + _computeUrl() { + const baseHref = this.env.session.url('/web'); + if (this.model === 'mail.channel') { + return `${baseHref}#action=mail.action_discuss&active_id=${this.model}_${this.id}`; + } + return `${baseHref}#model=${this.model}&id=${this.id}`; + } + + /** + * @private + * @param {Object} param0 + * @param {boolean} param0.isTyping + */ + async _notifyCurrentPartnerTypingStatus({ isTyping }) { + if ( + this._forceNotifyNextCurrentPartnerTypingStatus || + isTyping !== this._currentPartnerLastNotifiedIsTyping + ) { + if (this.model === 'mail.channel') { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'notify_typing', + args: [this.id], + kwargs: { is_typing: isTyping }, + }, { shadow: true })); + } + if (isTyping && this._currentPartnerLongTypingTimer.isRunning) { + this._currentPartnerLongTypingTimer.reset(); + } + } + this._forceNotifyNextCurrentPartnerTypingStatus = false; + this._currentPartnerLastNotifiedIsTyping = isTyping; + } + + /** + * Cleans followers of current thread. In particular, chats are supposed + * to work with "members", not with "followers". This clean up is only + * necessary to remove illegitimate followers in stable version, it can + * be removed in master after proper migration to clean the database. + * + * @private + */ + _onChangeFollowersPartner() { + if (this.channel_type !== 'chat') { + return; + } + for (const follower of this.followers) { + if (follower.partner) { + follower.remove(); + } + } + } + + /** + * @private + */ + _onChangeLastSeenByCurrentPartnerMessageId() { + this.env.messagingBus.trigger('o-thread-last-seen-by-current-partner-message-id-changed', { + thread: this, + }); + } + + /** + * @private + */ + _onChangeThreadViews() { + if (this.threadViews.length === 0) { + return; + } + /** + * Fetches followers of chats when they are displayed for the first + * time. This is necessary to clean the followers. + * @see `_onChangeFollowersPartner` for more information. + */ + if (this.channel_type === 'chat' && !this.areFollowersLoaded) { + this.refreshFollowers(); + } + } + + /** + * Handles change of pinned state coming from the server. Useful to + * clear pending state once server acknowledged the change. + * + * @private + * @see isPendingPinned + */ + _onIsServerPinnedChanged() { + if (this.isServerPinned === this.isPendingPinned) { + this.update({ isPendingPinned: clear() }); + } + } + + /** + * Handles change of fold state coming from the server. Useful to + * synchronize corresponding chat window. + * + * @private + */ + _onServerFoldStateChanged() { + if (!this.env.messaging.chatWindowManager) { + // avoid crash during destroy + return; + } + if (this.env.messaging.device.isMobile) { + return; + } + if (this.serverFoldState === 'closed') { + this.env.messaging.chatWindowManager.closeThread(this, { + notifyServer: false, + }); + } else { + this.env.messaging.chatWindowManager.openThread(this, { + isFolded: this.serverFoldState === 'folded', + notifyServer: false, + }); + } + } + + /** + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.mail_invite_follower_channel_only=false] + */ + _promptAddFollower({ mail_invite_follower_channel_only = false } = {}) { + const self = this; + const action = { + type: 'ir.actions.act_window', + res_model: 'mail.wizard.invite', + view_mode: 'form', + views: [[false, 'form']], + name: this.env._t("Invite Follower"), + target: 'new', + context: { + default_res_model: this.model, + default_res_id: this.id, + mail_invite_follower_channel_only, + }, + }; + this.env.bus.trigger('do-action', { + action, + options: { + on_close: async () => { + await this.async(() => this.refreshFollowers()); + this.env.bus.trigger('mail.thread:promptAddFollower-closed'); + }, + }, + }); + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * @private + */ + async _onCurrentPartnerInactiveTypingTimeout() { + await this.async(() => this.unregisterCurrentPartnerIsTyping()); + } + + /** + * Called when current partner has been typing for a very long time. + * Immediately notify other members that he/she is still typing. + * + * @private + */ + async _onCurrentPartnerLongTypingTimeout() { + this._forceNotifyNextCurrentPartnerTypingStatus = true; + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + await this.async( + () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true }) + ); + } + + /** + * @private + * @param {mail.partner} partner + */ + async _onOtherMemberLongTypingTimeout(partner) { + if (!this.typingMembers.includes(partner)) { + this._otherMembersLongTypingTimers.delete(partner); + return; + } + this.unregisterOtherMemberTypingMember(partner); + } + + } + + Thread.fields = { + /** + * Determines the `mail.activity` that belong to `this`, assuming `this` + * has activities (@see hasActivities). + */ + activities: one2many('mail.activity', { + inverse: 'thread', + }), + /** + * Serves as compute dependency. + */ + activitiesState: attr({ + related: 'activities.state', + }), + allAttachments: many2many('mail.attachment', { + compute: '_computeAllAttachments', + dependencies: [ + 'attachments', + 'originThreadAttachments', + ], + }), + areAttachmentsLoaded: attr({ + default: false, + }), + /** + * States whether followers have been loaded at least once for this + * thread. + */ + areFollowersLoaded: attr({ + default: false, + }), + attachments: many2many('mail.attachment', { + inverse: 'threads', + }), + caches: one2many('mail.thread_cache', { + inverse: 'thread', + isCausal: true, + }), + channel_type: attr(), + /** + * States the `mail.chat_window` related to `this`. Serves as compute + * dependency. It is computed from the inverse relation and it should + * otherwise be considered read-only. + */ + chatWindow: one2one('mail.chat_window', { + inverse: 'thread', + }), + /** + * Serves as compute dependency. + */ + chatWindowIsFolded: attr({ + related: 'chatWindow.isFolded', + }), + composer: one2one('mail.composer', { + default: [['create']], + inverse: 'thread', + isCausal: true, + }), + correspondent: many2one('mail.partner', { + compute: '_computeCorrespondent', + dependencies: [ + 'channel_type', + 'members', + 'messagingCurrentPartner', + ], + inverse: 'correspondentThreads', + }), + correspondentNameOrDisplayName: attr({ + related: 'correspondent.nameOrDisplayName', + }), + counter: attr({ + default: 0, + }), + creator: many2one('mail.user'), + custom_channel_name: attr(), + displayName: attr({ + compute: '_computeDisplayName', + dependencies: [ + 'channel_type', + 'correspondent', + 'correspondentNameOrDisplayName', + 'custom_channel_name', + 'name', + ], + }), + followersPartner: many2many('mail.partner', { + related: 'followers.partner', + }), + followers: one2many('mail.follower', { + inverse: 'followedThread', + }), + /** + * States the `mail.activity` that belongs to `this` and that are + * planned in the future (due later than today). + */ + futureActivities: one2many('mail.activity', { + compute: '_computeFutureActivities', + dependencies: ['activitiesState'], + }), + group_based_subscription: attr({ + default: false, + }), + /** + * States whether `this` has activities (`mail.activity.mixin` server side). + */ + hasActivities: attr({ + default: false, + }), + /** + * Determine whether this thread has the seen indicators (V and VV) + * enabled or not. + */ + hasSeenIndicators: attr({ + compute: '_computeHasSeenIndicators', + default: false, + dependencies: [ + 'channel_type', + 'mass_mailing', + 'model', + ], + }), + id: attr(), + /** + * States whether this thread is a `mail.channel` qualified as chat. + * + * Useful to list chat channels, like in messaging menu with the filter + * 'chat'. + */ + isChatChannel: attr({ + compute: '_computeIsChatChannel', + dependencies: [ + 'channel_type', + ], + default: false, + }), + isCurrentPartnerFollowing: attr({ + compute: '_computeIsCurrentPartnerFollowing', + default: false, + dependencies: [ + 'followersPartner', + 'messagingCurrentPartner', + ], + }), + /** + * States whether `this` is currently loading attachments. + */ + isLoadingAttachments: attr({ + default: false, + }), + isModeratedByCurrentPartner: attr({ + compute: '_computeIsModeratedByCurrentPartner', + dependencies: [ + 'messagingCurrentPartner', + 'moderators', + ], + }), + /** + * Determine if there is a pending pin state change, which is a change + * of pin state requested by the client but not yet confirmed by the + * server. + * + * This field can be updated to immediately change the pin state on the + * interface and to notify the server of the new state. + */ + isPendingPinned: attr(), + /** + * Boolean that determines whether this thread is pinned + * in discuss and present in the messaging menu. + */ + isPinned: attr({ + compute: '_computeIsPinned', + dependencies: [ + 'isPendingPinned', + 'isServerPinned', + ], + }), + /** + * Determine the last pin state known by the server, which is the pin + * state displayed after initialization or when the last pending + * pin state change was confirmed by the server. + * + * This field should be considered read only in most situations. Only + * the code handling pin state change from the server should typically + * update it. + */ + isServerPinned: attr({ + default: false, + }), + isTemporary: attr({ + default: false, + }), + is_moderator: attr({ + default: false, + }), + lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', { + compute: '_computeLastCurrentPartnerMessageSeenByEveryone', + dependencies: [ + 'messagingCurrentPartner', + 'orderedNonTransientMessages', + 'partnerSeenInfos', + ], + }), + /** + * Last message of the thread, could be a transient one. + */ + lastMessage: many2one('mail.message', { + compute: '_computeLastMessage', + dependencies: ['orderedMessages'], + }), + lastNeedactionMessage: many2one('mail.message', { + compute: '_computeLastNeedactionMessage', + dependencies: ['needactionMessages'], + }), + /** + * States the last known needaction message having this thread as origin. + */ + lastNeedactionMessageAsOriginThread: many2one('mail.message', { + compute: '_computeLastNeedactionMessageAsOriginThread', + dependencies: [ + 'needactionMessagesAsOriginThread', + ], + }), + /** + * Last non-transient message. + */ + lastNonTransientMessage: many2one('mail.message', { + compute: '_computeLastNonTransientMessage', + dependencies: ['orderedNonTransientMessages'], + }), + /** + * Last seen message id of the channel by current partner. + * + * Also, it needs to be kept as an id because it's considered like a "date" and could stay + * even if corresponding message is deleted. It is basically used to know which + * messages are before or after it. + */ + lastSeenByCurrentPartnerMessageId: attr({ + compute: '_computeLastSeenByCurrentPartnerMessageId', + default: 0, + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + 'messagingCurrentPartner', + 'orderedMessages', + 'orderedMessagesIsTransient', + // FIXME missing dependency 'orderedMessages.author', (task-2261221) + ], + }), + /** + * Local value of message unread counter, that means it is based on initial server value and + * updated with interface updates. + */ + localMessageUnreadCounter: attr({ + compute: '_computeLocalMessageUnreadCounter', + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + 'messagingCurrentPartner', + 'orderedMessages', + 'serverLastMessageId', + 'serverMessageUnreadCounter', + ], + }), + mainCache: one2one('mail.thread_cache', { + compute: '_computeMainCache', + }), + mass_mailing: attr({ + default: false, + }), + members: many2many('mail.partner', { + inverse: 'memberThreads', + }), + /** + * Determines the message before which the "new message" separator must + * be positioned, if any. + */ + messageAfterNewMessageSeparator: many2one('mail.message', { + compute: '_computeMessageAfterNewMessageSeparator', + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + 'localMessageUnreadCounter', + 'model', + 'orderedMessages', + ], + }), + message_needaction_counter: attr({ + default: 0, + }), + /** + * All messages that this thread is linked to. + * Note that this field is automatically computed by inverse + * computed field. This field is readonly. + */ + messages: many2many('mail.message', { + inverse: 'threads', + }), + /** + * All messages that have been originally posted in this thread. + */ + messagesAsOriginThread: one2many('mail.message', { + inverse: 'originThread', + }), + /** + * Serves as compute dependency. + */ + messagesAsOriginThreadIsNeedaction: attr({ + related: 'messagesAsOriginThread.isNeedaction', + }), + /** + * All messages that are contained on this channel on the server. + * Equivalent to the inverse of python field `channel_ids`. + */ + messagesAsServerChannel: many2many('mail.message', { + inverse: 'serverChannels', + }), + /** + * Serves as compute dependency. + */ + messagesIsNeedaction: attr({ + related: 'messages.isNeedaction', + }), + messageSeenIndicators: one2many('mail.message_seen_indicator', { + inverse: 'thread', + isCausal: true, + }), + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + messagingCurrentPartner: many2one('mail.partner', { + related: 'messaging.currentPartner', + }), + model: attr(), + model_name: attr(), + moderation: attr({ + default: false, + }), + /** + * Partners that are moderating this thread (only applies to channels). + */ + moderators: many2many('mail.partner', { + inverse: 'moderatedChannels', + }), + moduleIcon: attr(), + name: attr(), + needactionMessages: many2many('mail.message', { + compute: '_computeNeedactionMessages', + dependencies: [ + 'messages', + 'messagesIsNeedaction', + ], + }), + /** + * States all known needaction messages having this thread as origin. + */ + needactionMessagesAsOriginThread: many2many('mail.message', { + compute: '_computeNeedactionMessagesAsOriginThread', + dependencies: [ + 'messagesAsOriginThread', + 'messagesAsOriginThreadIsNeedaction', + ], + }), + /** + * Not a real field, used to trigger `_onChangeFollowersPartner` when one of + * the dependencies changes. + */ + onChangeFollowersPartner: attr({ + compute: '_onChangeFollowersPartner', + dependencies: [ + 'followersPartner', + ], + }), + /** + * Not a real field, used to trigger `_onChangeLastSeenByCurrentPartnerMessageId` when one of + * the dependencies changes. + */ + onChangeLastSeenByCurrentPartnerMessageId: attr({ + compute: '_onChangeLastSeenByCurrentPartnerMessageId', + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + ], + }), + /** + * Not a real field, used to trigger `_onChangeThreadViews` when one of + * the dependencies changes. + */ + onChangeThreadView: attr({ + compute: '_onChangeThreadViews', + dependencies: [ + 'threadViews', + ], + }), + /** + * Not a real field, used to trigger `_onIsServerPinnedChanged` when one of + * the dependencies changes. + */ + onIsServerPinnedChanged: attr({ + compute: '_onIsServerPinnedChanged', + dependencies: [ + 'isServerPinned', + ], + }), + /** + * Not a real field, used to trigger `_onServerFoldStateChanged` when one of + * the dependencies changes. + */ + onServerFoldStateChanged: attr({ + compute: '_onServerFoldStateChanged', + dependencies: [ + 'serverFoldState', + ], + }), + /** + * All messages ordered like they are displayed. + */ + orderedMessages: many2many('mail.message', { + compute: '_computeOrderedMessages', + dependencies: ['messages'], + }), + /** + * Serves as compute dependency. (task-2261221) + */ + orderedMessagesIsTransient: attr({ + related: 'orderedMessages.isTransient', + }), + /** + * All messages ordered like they are displayed. This field does not + * contain transient messages which are not "real" records. + */ + orderedNonTransientMessages: many2many('mail.message', { + compute: '_computeOrderedNonTransientMessages', + dependencies: [ + 'orderedMessages', + 'orderedMessagesIsTransient', + ], + }), + /** + * Ordered typing members on this thread, excluding the current partner. + */ + orderedOtherTypingMembers: many2many('mail.partner', { + compute: '_computeOrderedOtherTypingMembers', + dependencies: ['orderedTypingMembers'], + }), + /** + * Ordered typing members on this thread. Lower index means this member + * is currently typing for the longest time. This list includes current + * partner as typer. + */ + orderedTypingMembers: many2many('mail.partner', { + compute: '_computeOrderedTypingMembers', + dependencies: [ + 'orderedTypingMemberLocalIds', + 'typingMembers', + ], + }), + /** + * Technical attribute to manage ordered list of typing members. + */ + orderedTypingMemberLocalIds: attr({ + default: [], + }), + originThreadAttachments: one2many('mail.attachment', { + inverse: 'originThread', + }), + /** + * States the `mail.activity` that belongs to `this` and that are + * overdue (due earlier than today). + */ + overdueActivities: one2many('mail.activity', { + compute: '_computeOverdueActivities', + dependencies: ['activitiesState'], + }), + partnerSeenInfos: one2many('mail.thread_partner_seen_info', { + inverse: 'thread', + isCausal: true, + }), + /** + * Determine if there is a pending seen message change, which is a change + * of seen message requested by the client but not yet confirmed by the + * server. + */ + pendingSeenMessageId: attr(), + public: attr(), + /** + * Determine the last fold state known by the server, which is the fold + * state displayed after initialization or when the last pending + * fold state change was confirmed by the server. + * + * This field should be considered read only in most situations. Only + * the code handling fold state change from the server should typically + * update it. + */ + serverFoldState: attr({ + default: 'closed', + }), + /** + * Last message id considered by the server. + * + * Useful to compute localMessageUnreadCounter field. + * + * @see localMessageUnreadCounter + */ + serverLastMessageId: attr({ + default: 0, + }), + /** + * Message unread counter coming from server. + * + * Value of this field is unreliable, due to dynamic nature of + * messaging. So likely outdated/unsync with server. Should use + * localMessageUnreadCounter instead, which smartly guess the actual + * message unread counter at all time. + * + * @see localMessageUnreadCounter + */ + serverMessageUnreadCounter: attr({ + default: 0, + }), + /** + * Determines the `mail.suggested_recipient_info` concerning `this`. + */ + suggestedRecipientInfoList: one2many('mail.suggested_recipient_info', { + inverse: 'thread', + }), + threadViews: one2many('mail.thread_view', { + inverse: 'thread', + }), + /** + * States the `mail.activity` that belongs to `this` and that are due + * specifically today. + */ + todayActivities: one2many('mail.activity', { + compute: '_computeTodayActivities', + dependencies: ['activitiesState'], + }), + /** + * Members that are currently typing something in the composer of this + * thread, including current partner. + */ + typingMembers: many2many('mail.partner'), + /** + * Text that represents the status on this thread about typing members. + */ + typingStatusText: attr({ + compute: '_computeTypingStatusText', + default: '', + dependencies: ['orderedOtherTypingMembers'], + }), + /** + * URL to access to the conversation. + */ + url: attr({ + compute: '_computeUrl', + default: '', + dependencies: [ + 'id', + 'model', + ] + }), + uuid: attr(), + }; + + Thread.modelName = 'mail.thread'; + + return Thread; +} + +registerNewModel('mail.thread', factory); + +}); diff --git a/addons/mail/static/src/models/thread/thread_tests.js b/addons/mail/static/src/models/thread/thread_tests.js new file mode 100644 index 00000000..a535cf4e --- /dev/null +++ b/addons/mail/static/src/models/thread/thread_tests.js @@ -0,0 +1,150 @@ +odoo.define('mail/static/src/models/thread/thread_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('models', {}, function () { +QUnit.module('thread', {}, function () { +QUnit.module('thread_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('inbox & starred mailboxes', async function (assert) { + assert.expect(10); + + await this.start(); + const mailboxInbox = this.env.messaging.inbox; + const mailboxStarred = this.env.messaging.starred; + assert.ok(mailboxInbox, "should have mailbox inbox"); + assert.ok(mailboxStarred, "should have mailbox starred"); + assert.strictEqual(mailboxInbox.model, 'mail.box'); + assert.strictEqual(mailboxInbox.counter, 0); + assert.strictEqual(mailboxInbox.id, 'inbox'); + assert.strictEqual(mailboxInbox.name, "Inbox"); // language-dependent + assert.strictEqual(mailboxStarred.model, 'mail.box'); + assert.strictEqual(mailboxStarred.counter, 0); + assert.strictEqual(mailboxStarred.id, 'starred'); + assert.strictEqual(mailboxStarred.name, "Starred"); // language-dependent +}); + +QUnit.test('create (channel)', async function (assert) { + assert.expect(23); + + await this.start(); + assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 })); + assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 })); + assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + })); + + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [{ + email: "john@example.com", + id: 9, + name: "John", + }, { + email: "fred@example.com", + id: 10, + name: "Fred", + }]]], + message_needaction_counter: 6, + model: 'mail.channel', + name: "General", + public: 'public', + serverMessageUnreadCounter: 5, + }); + assert.ok(thread); + assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 })); + assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 })); + assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + })); + const partner9 = this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 }); + const partner10 = this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 }); + assert.strictEqual(thread, this.env.models['mail.thread'].findFromIdentifyingData({ + id: 100, + model: 'mail.channel', + })); + assert.strictEqual(thread.model, 'mail.channel'); + assert.strictEqual(thread.channel_type, 'channel'); + assert.strictEqual(thread.id, 100); + assert.ok(thread.members.includes(partner9)); + assert.ok(thread.members.includes(partner10)); + assert.strictEqual(thread.message_needaction_counter, 6); + assert.strictEqual(thread.name, "General"); + assert.strictEqual(thread.public, 'public'); + assert.strictEqual(thread.serverMessageUnreadCounter, 5); + assert.strictEqual(partner9.email, "john@example.com"); + assert.strictEqual(partner9.id, 9); + assert.strictEqual(partner9.name, "John"); + assert.strictEqual(partner10.email, "fred@example.com"); + assert.strictEqual(partner10.id, 10); + assert.strictEqual(partner10.name, "Fred"); +}); + +QUnit.test('create (chat)', async function (assert) { + assert.expect(15); + + await this.start(); + assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 })); + assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({ + id: 200, + model: 'mail.channel', + })); + + const channel = this.env.models['mail.thread'].create({ + channel_type: 'chat', + id: 200, + members: [['insert', { + email: "demo@example.com", + id: 5, + im_status: 'online', + name: "Demo", + }]], + model: 'mail.channel', + }); + assert.ok(channel); + assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({ + id: 200, + model: 'mail.channel', + })); + assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 })); + const partner = this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }); + assert.strictEqual(channel, this.env.models['mail.thread'].findFromIdentifyingData({ + id: 200, + model: 'mail.channel', + })); + assert.strictEqual(channel.model, 'mail.channel'); + assert.strictEqual(channel.channel_type, 'chat'); + assert.strictEqual(channel.id, 200); + assert.ok(channel.correspondent); + assert.strictEqual(partner, channel.correspondent); + assert.strictEqual(partner.email, "demo@example.com"); + assert.strictEqual(partner.id, 5); + assert.strictEqual(partner.im_status, 'online'); + assert.strictEqual(partner.name, "Demo"); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/models/thread_cache/thread_cache.js b/addons/mail/static/src/models/thread_cache/thread_cache.js new file mode 100644 index 00000000..1760a509 --- /dev/null +++ b/addons/mail/static/src/models/thread_cache/thread_cache.js @@ -0,0 +1,617 @@ +odoo.define('mail/static/src/models/thread_cache/thread_cache.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ThreadCache extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @returns {mail.message[]|undefined} + */ + async loadMoreMessages() { + if (this.isAllHistoryLoaded || this.isLoading) { + return; + } + if (!this.isLoaded) { + this.update({ isCacheRefreshRequested: true }); + return; + } + this.update({ isLoadingMore: true }); + const messageIds = this.fetchedMessages.map(message => message.id); + const limit = 30; + const fetchedMessages = await this.async(() => this._loadMessages({ + extraDomain: [['id', '<', Math.min(...messageIds)]], + limit, + })); + this.update({ isLoadingMore: false }); + if (fetchedMessages.length < limit) { + this.update({ isAllHistoryLoaded: true }); + } + for (const threadView of this.threadViews) { + threadView.addComponentHint('more-messages-loaded', { fetchedMessages }); + } + return fetchedMessages; + } + + /** + * @returns {mail.message[]|undefined} + */ + async loadNewMessages() { + if (this.isLoading) { + return; + } + if (!this.isLoaded) { + this.update({ isCacheRefreshRequested: true }); + return; + } + const messageIds = this.fetchedMessages.map(message => message.id); + const fetchedMessages = this._loadMessages({ + extraDomain: [['id', '>', Math.max(...messageIds)]], + limit: false, + }); + for (const threadView of this.threadViews) { + threadView.addComponentHint('new-messages-loaded', { fetchedMessages }); + } + return fetchedMessages; + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + const { + stringifiedDomain = '[]', + thread: [[commandInsert, thread]], + } = data; + return `${this.modelName}_[${thread.localId}]_<${stringifiedDomain}>`; + } + + /** + * @private + */ + _computeCheckedMessages() { + const messagesWithoutCheckbox = this.checkedMessages.filter( + message => !message.hasCheckbox + ); + return [['unlink', messagesWithoutCheckbox]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeFetchedMessages() { + if (!this.thread) { + return [['unlink-all']]; + } + const toUnlinkMessages = []; + for (const message of this.fetchedMessages) { + if (!this.thread.messages.includes(message)) { + toUnlinkMessages.push(message); + } + } + return [['unlink', toUnlinkMessages]]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastFetchedMessage() { + const { + length: l, + [l - 1]: lastFetchedMessage, + } = this.orderedFetchedMessages; + if (!lastFetchedMessage) { + return [['unlink']]; + } + return [['link', lastFetchedMessage]]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastMessage() { + const { + length: l, + [l - 1]: lastMessage, + } = this.orderedMessages; + if (!lastMessage) { + return [['unlink']]; + } + return [['link', lastMessage]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeMessages() { + if (!this.thread) { + return [['unlink-all']]; + } + let messages = this.fetchedMessages; + if (this.stringifiedDomain !== '[]') { + return [['replace', messages]]; + } + // main cache: adjust with newer messages + let newerMessages; + if (!this.lastFetchedMessage) { + newerMessages = this.thread.messages; + } else { + newerMessages = this.thread.messages.filter(message => + message.id > this.lastFetchedMessage.id + ); + } + messages = messages.concat(newerMessages); + return [['replace', messages]]; + } + + /** + * + * @private + * @returns {mail.message[]} + */ + _computeNonEmptyMessages() { + return [['replace', this.messages.filter(message => !message.isEmpty)]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeOrderedFetchedMessages() { + return [['replace', this.fetchedMessages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeOrderedMessages() { + return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasToLoadMessages() { + if (!this.thread) { + // happens during destroy or compute executed in wrong order + return false; + } + const wasCacheRefreshRequested = this.isCacheRefreshRequested; + // mark hint as processed + if (this.isCacheRefreshRequested) { + this.update({ isCacheRefreshRequested: false }); + } + if (this.thread.isTemporary) { + // temporary threads don't exist on the server + return false; + } + if (!wasCacheRefreshRequested && this.threadViews.length === 0) { + // don't load message that won't be used + return false; + } + if (this.isLoading) { + // avoid duplicate RPC + return false; + } + if (!wasCacheRefreshRequested && this.isLoaded) { + // avoid duplicate RPC + return false; + } + const isMainCache = this.thread.mainCache === this; + if (isMainCache && this.isLoaded) { + // Ignore request on the main cache if it is already loaded or + // loading. Indeed the main cache is automatically sync with + // server updates already, so there is never a need to refresh + // it past the first time. + return false; + } + return true; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeUncheckedMessages() { + return [['replace', this.messages.filter( + message => message.hasCheckbox && !this.checkedMessages.includes(message) + )]]; + } + + /** + * @private + * @param {Array} domain + * @returns {Array} + */ + _extendMessageDomain(domain) { + const thread = this.thread; + if (thread.model === 'mail.channel') { + return domain.concat([['channel_ids', 'in', [thread.id]]]); + } else if (thread === this.env.messaging.inbox) { + return domain.concat([['needaction', '=', true]]); + } else if (thread === this.env.messaging.starred) { + return domain.concat([ + ['starred_partner_ids', 'in', [this.env.messaging.currentPartner.id]], + ]); + } else if (thread === this.env.messaging.history) { + return domain.concat([['needaction', '=', false]]); + } else if (thread === this.env.messaging.moderation) { + return domain.concat([['moderation_status', '=', 'pending_moderation']]); + } else { + // Avoid to load user_notification as these messages are not + // meant to be shown on chatters. + return domain.concat([ + ['message_type', '!=', 'user_notification'], + ['model', '=', thread.model], + ['res_id', '=', thread.id], + ]); + } + } + + /** + * @private + * @param {Object} [param0={}] + * @param {Array[]} [param0.extraDomain] + * @param {integer} [param0.limit=30] + * @returns {mail.message[]} + */ + async _loadMessages({ extraDomain, limit = 30 } = {}) { + this.update({ isLoading: true }); + const searchDomain = JSON.parse(this.stringifiedDomain); + let domain = searchDomain.length ? searchDomain : []; + domain = this._extendMessageDomain(domain); + if (extraDomain) { + domain = extraDomain.concat(domain); + } + const context = this.env.session.user_context; + const moderated_channel_ids = this.thread.moderation + ? [this.thread.id] + : undefined; + const messages = await this.async(() => + this.env.models['mail.message'].performRpcMessageFetch( + domain, + limit, + moderated_channel_ids, + context, + ) + ); + this.update({ + fetchedMessages: [['link', messages]], + isLoaded: true, + isLoading: false, + }); + if (!extraDomain && messages.length < limit) { + this.update({ isAllHistoryLoaded: true }); + } + this.env.messagingBus.trigger('o-thread-cache-loaded-messages', { + fetchedMessages: messages, + threadCache: this, + }); + return messages; + } + + /** + * Calls "mark all as read" when this thread becomes displayed in a + * view (which is notified by `isMarkAllAsReadRequested` being `true`), + * but delays the call until some other conditions are met, such as the + * messages being loaded. + * The reason to wait until messages are loaded is to avoid a race + * condition because "mark all as read" will change the state of the + * messages in parallel to fetch reading them. + * + * @private + */ + _onChangeMarkAllAsRead() { + if ( + !this.isMarkAllAsReadRequested || + !this.thread || + !this.thread.mainCache || + !this.isLoaded || + this.isLoading + ) { + // wait for change of state before deciding what to do + return; + } + this.update({ isMarkAllAsReadRequested: false }); + if ( + this.thread.isTemporary || + this.thread.model === 'mail.box' || + this.thread.mainCache !== this || + this.threadViews.length === 0 + ) { + // ignore the request + return; + } + this.env.models['mail.message'].markAllAsRead([ + ['model', '=', this.thread.model], + ['res_id', '=', this.thread.id], + ]); + } + + /** + * Loads this thread cache, by fetching the most recent messages in this + * conversation. + * + * @private + */ + _onHasToLoadMessagesChanged() { + if (!this.hasToLoadMessages) { + return; + } + this._loadMessages().then(fetchedMessages => { + for (const threadView of this.threadViews) { + threadView.addComponentHint('messages-loaded', { fetchedMessages }); + } + }); + } + + /** + * Handles change of messages on this thread cache. This is useful to + * refresh non-main caches that are currently displayed when the main + * cache receives updates. This is necessary because only the main cache + * is aware of changes in real time. + */ + _onMessagesChanged() { + if (!this.thread) { + return; + } + if (this.thread.mainCache !== this) { + return; + } + for (const threadView of this.thread.threadViews) { + if (threadView.threadCache) { + threadView.threadCache.update({ isCacheRefreshRequested: true }); + } + } + } + + } + + ThreadCache.fields = { + checkedMessages: many2many('mail.message', { + compute: '_computeCheckedMessages', + dependencies: [ + 'checkedMessages', + 'messagesCheckboxes', + ], + inverse: 'checkedThreadCaches', + }), + /** + * List of messages that have been fetched by this cache. + * + * This DOES NOT necessarily includes all messages linked to this thread + * cache (@see messages field for that): it just contains list + * of successive messages that have been explicitly fetched by this + * cache. For all non-main caches, this corresponds to all messages. + * For the main cache, however, messages received from longpolling + * should be displayed on main cache but they have not been explicitly + * fetched by cache, so they ARE NOT in this list (at least, not until a + * fetch on this thread cache contains this message). + * + * The distinction between messages and fetched messages is important + * to manage "holes" in message list, while still allowing to display + * new messages on main cache of thread in real-time. + */ + fetchedMessages: many2many('mail.message', { + // adjust with messages unlinked from thread + compute: '_computeFetchedMessages', + dependencies: ['threadMessages'], + }), + /** + * Determines whether `this` should load initial messages. This field is + * computed and should be considered read-only. + * @see `isCacheRefreshRequested` to request manual refresh of messages. + */ + hasToLoadMessages: attr({ + compute: '_computeHasToLoadMessages', + dependencies: [ + 'isCacheRefreshRequested', + 'isLoaded', + 'isLoading', + 'thread', + 'threadIsTemporary', + 'threadMainCache', + 'threadViews', + ], + }), + isAllHistoryLoaded: attr({ + default: false, + }), + isLoaded: attr({ + default: false, + }), + isLoading: attr({ + default: false, + }), + isLoadingMore: attr({ + default: false, + }), + /** + * Determines whether `this` should consider refreshing its messages. + * This field is a hint that may or may not lead to an actual refresh. + * @see `hasToLoadMessages` + */ + isCacheRefreshRequested: attr({ + default: false, + }), + /** + * Determines whether this cache should consider calling "mark all as + * read" on this thread. + * + * This field is a hint that may or may not lead to an actual call. + * @see `_onChangeMarkAllAsRead` + */ + isMarkAllAsReadRequested: attr({ + default: false, + }), + /** + * Last message that has been fetched by this thread cache. + * + * This DOES NOT necessarily mean the last message linked to this thread + * cache (@see lastMessage field for that). @see fetchedMessages field + * for a deeper explanation about "fetched" messages. + */ + lastFetchedMessage: many2one('mail.message', { + compute: '_computeLastFetchedMessage', + dependencies: ['orderedFetchedMessages'], + }), + lastMessage: many2one('mail.message', { + compute: '_computeLastMessage', + dependencies: ['orderedMessages'], + }), + messagesCheckboxes: attr({ + related: 'messages.hasCheckbox', + }), + /** + * List of messages linked to this cache. + */ + messages: many2many('mail.message', { + compute: '_computeMessages', + dependencies: [ + 'fetchedMessages', + 'threadMessages', + ], + }), + /** + * IsEmpty trait of all messages. + * Serves as compute dependency. + */ + messagesAreEmpty: attr({ + related: 'messages.isEmpty' + }), + /** + * List of non empty messages linked to this cache. + */ + nonEmptyMessages: many2many('mail.message', { + compute: '_computeNonEmptyMessages', + dependencies: [ + 'messages', + 'messagesAreEmpty', + ], + }), + /** + * Not a real field, used to trigger its compute method when one of the + * dependencies changes. + */ + onChangeMarkAllAsRead: attr({ + compute: '_onChangeMarkAllAsRead', + dependencies: [ + 'isLoaded', + 'isLoading', + 'isMarkAllAsReadRequested', + 'thread', + 'threadIsTemporary', + 'threadMainCache', + 'threadModel', + 'threadViews', + ], + }), + /** + * Loads initial messages from `this`. + * This is not a "real" field, its compute function is used to trigger + * the load of messages at the right time. + */ + onHasToLoadMessagesChanged: attr({ + compute: '_onHasToLoadMessagesChanged', + dependencies: [ + 'hasToLoadMessages', + ], + }), + /** + * Not a real field, used to trigger `_onMessagesChanged` when one of + * the dependencies changes. + */ + onMessagesChanged: attr({ + compute: '_onMessagesChanged', + dependencies: [ + 'messages', + 'thread', + 'threadMainCache', + ], + }), + /** + * Ordered list of messages that have been fetched by this cache. + * + * This DOES NOT necessarily includes all messages linked to this thread + * cache (@see orderedMessages field for that). @see fetchedMessages + * field for deeper explanation about "fetched" messages. + */ + orderedFetchedMessages: many2many('mail.message', { + compute: '_computeOrderedFetchedMessages', + dependencies: ['fetchedMessages'], + }), + /** + * Ordered list of messages linked to this cache. + */ + orderedMessages: many2many('mail.message', { + compute: '_computeOrderedMessages', + dependencies: ['messages'], + }), + stringifiedDomain: attr({ + default: '[]', + }), + thread: many2one('mail.thread', { + inverse: 'caches', + }), + /** + * Serves as compute dependency. + */ + threadIsTemporary: attr({ + related: 'thread.isTemporary', + }), + /** + * Serves as compute dependency. + */ + threadMainCache: many2one('mail.thread_cache', { + related: 'thread.mainCache', + }), + threadMessages: many2many('mail.message', { + related: 'thread.messages', + }), + /** + * Serves as compute dependency. + */ + threadModel: attr({ + related: 'thread.model', + }), + /** + * States the 'mail.thread_view' that are currently displaying `this`. + */ + threadViews: one2many('mail.thread_view', { + inverse: 'threadCache', + }), + uncheckedMessages: many2many('mail.message', { + compute: '_computeUncheckedMessages', + dependencies: [ + 'checkedMessages', + 'messagesCheckboxes', + 'messages', + ], + }), + }; + + ThreadCache.modelName = 'mail.thread_cache'; + + return ThreadCache; +} + +registerNewModel('mail.thread_cache', factory); + +}); diff --git a/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js new file mode 100644 index 00000000..8fd3b95a --- /dev/null +++ b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js @@ -0,0 +1,109 @@ +odoo.define('mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ThreadPartnerSeenInfo extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + const { channelId, partnerId } = data; + return `${this.modelName}_${channelId}_${partnerId}`; + } + + /** + * @private + * @returns {mail.partner|undefined} + */ + _computePartner() { + return [['insert', { id: this.partnerId }]]; + } + + /** + * @private + * @returns {mail.thread|undefined} + */ + _computeThread() { + return [['insert', { + id: this.channelId, + model: 'mail.channel', + }]]; + } + + } + + ThreadPartnerSeenInfo.modelName = 'mail.thread_partner_seen_info'; + + ThreadPartnerSeenInfo.fields = { + /** + * The id of channel this seen info is related to. + * + * Should write on this field to set relation between the channel and + * this seen info, not on `thread`. + * + * Reason for not setting the relation directly is the necessity to + * uniquely identify a seen info based on channel and partner from data. + * Relational data are list of commands, which is problematic to deduce + * identifying records. + * + * TODO: task-2322536 (normalize relational data) & task-2323665 + * (required fields) should improve and let us just use the relational + * fields. + */ + channelId: attr(), + lastFetchedMessage: many2one('mail.message'), + lastSeenMessage: many2one('mail.message'), + /** + * Partner that this seen info is related to. + * + * Should not write on this field to update relation, and instead + * should write on @see partnerId field. + */ + partner: many2one('mail.partner', { + compute: '_computePartner', + dependencies: ['partnerId'], + }), + /** + * The id of partner this seen info is related to. + * + * Should write on this field to set relation between the partner and + * this seen info, not on `partner`. + * + * Reason for not setting the relation directly is the necessity to + * uniquely identify a seen info based on channel and partner from data. + * Relational data are list of commands, which is problematic to deduce + * identifying records. + * + * TODO: task-2322536 (normalize relational data) & task-2323665 + * (required fields) should improve and let us just use the relational + * fields. + */ + partnerId: attr(), + /** + * Thread (channel) that this seen info is related to. + * + * Should not write on this field to update relation, and instead + * should write on @see channelId field. + */ + thread: many2one('mail.thread', { + compute: '_computeThread', + dependencies: ['channelId'], + inverse: 'partnerSeenInfos', + }), + }; + + return ThreadPartnerSeenInfo; +} + +registerNewModel('mail.thread_partner_seen_info', factory); + +}); diff --git a/addons/mail/static/src/models/thread_view/thread_view.js b/addons/mail/static/src/models/thread_view/thread_view.js new file mode 100644 index 00000000..a7ccf0c7 --- /dev/null +++ b/addons/mail/static/src/models/thread_view/thread_view.js @@ -0,0 +1,441 @@ +odoo.define('mail/static/src/models/thread_view/thread_view.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { RecordDeletedError } = require('mail/static/src/model/model_errors.js'); +const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); + +function factory(dependencies) { + + class ThreadView extends dependencies['mail.model'] { + + /** + * @override + */ + _willDelete() { + this.env.browser.clearTimeout(this._loaderTimeout); + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * This function register a hint for the component related to this + * record. Hints are information on changes around this viewer that + * make require adjustment on the component. For instance, if this + * ThreadView initiated a thread cache load and it now has become + * loaded, then it may need to auto-scroll to last message. + * + * @param {string} hintType name of the hint. Used to determine what's + * the broad type of adjustement the component has to do. + * @param {any} [hintData] data of the hint. Used to fine-tune + * adjustments on the component. + */ + addComponentHint(hintType, hintData) { + const hint = { data: hintData, type: hintType }; + this.update({ + componentHintList: this.componentHintList.concat([hint]), + }); + } + + /** + * @param {Object} hint + */ + markComponentHintProcessed(hint) { + this.update({ + componentHintList: this.componentHintList.filter(h => h !== hint), + }); + this.env.messagingBus.trigger('o-thread-view-hint-processed', { + hint, + threadViewer: this.threadViewer, + }); + } + + /** + * @param {mail.message} message + */ + handleVisibleMessage(message) { + if (!this.lastVisibleMessage || this.lastVisibleMessage.id < message.id) { + this.update({ lastVisibleMessage: [['link', message]] }); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * @private + * @returns {string[]} + */ + _computeTextInputSendShortcuts() { + if (!this.thread) { + return; + } + const isMailingList = this.thread.model === 'mail.channel' && this.thread.mass_mailing; + // Actually in mobile there is a send button, so we need there 'enter' to allow new line. + // Hence, we want to use a different shortcut 'ctrl/meta enter' to send for small screen + // size with a non-mailing channel. + // here send will be done on clicking the button or using the 'ctrl/meta enter' shortcut. + if (this.env.messaging.device.isMobile || isMailingList) { + return ['ctrl-enter', 'meta-enter']; + } + return ['enter']; + } + + /** + * @private + * @returns {integer|undefined} + */ + _computeThreadCacheInitialScrollHeight() { + if (!this.threadCache) { + return clear(); + } + const threadCacheInitialScrollHeight = this.threadCacheInitialScrollHeights[this.threadCache.localId]; + if (threadCacheInitialScrollHeight !== undefined) { + return threadCacheInitialScrollHeight; + } + return clear(); + } + + /** + * @private + * @returns {integer|undefined} + */ + _computeThreadCacheInitialScrollPosition() { + if (!this.threadCache) { + return clear(); + } + const threadCacheInitialScrollPosition = this.threadCacheInitialScrollPositions[this.threadCache.localId]; + if (threadCacheInitialScrollPosition !== undefined) { + return threadCacheInitialScrollPosition; + } + return clear(); + } + + /** + * Not a real field, used to trigger `thread.markAsSeen` when one of + * the dependencies changes. + * + * @private + * @returns {boolean} + */ + _computeThreadShouldBeSetAsSeen() { + if (!this.thread) { + return; + } + if (!this.thread.lastNonTransientMessage) { + return; + } + if (!this.lastVisibleMessage) { + return; + } + if (this.lastVisibleMessage !== this.lastMessage) { + return; + } + if (!this.hasComposerFocus) { + // FIXME condition should not be on "composer is focused" but "threadView is active" + // See task-2277543 + return; + } + this.thread.markAsSeen(this.thread.lastNonTransientMessage).catch(e => { + // prevent crash when executing compute during destroy + if (!(e instanceof RecordDeletedError)) { + throw e; + } + }); + } + + /** + * @private + */ + _onThreadCacheChanged() { + // clear obsolete hints + this.update({ componentHintList: clear() }); + this.addComponentHint('change-of-thread-cache'); + if (this.threadCache) { + this.threadCache.update({ + isCacheRefreshRequested: true, + isMarkAllAsReadRequested: true, + }); + } + this.update({ lastVisibleMessage: [['unlink']] }); + } + + /** + * @private + */ + _onThreadCacheIsLoadingChanged() { + if (this.threadCache && this.threadCache.isLoading) { + if (!this.isLoading && !this.isPreparingLoading) { + this.update({ isPreparingLoading: true }); + this.async(() => + new Promise(resolve => { + this._loaderTimeout = this.env.browser.setTimeout(resolve, 400); + } + )).then(() => { + const isLoading = this.threadCache + ? this.threadCache.isLoading + : false; + this.update({ isLoading, isPreparingLoading: false }); + }); + } + return; + } + this.env.browser.clearTimeout(this._loaderTimeout); + this.update({ isLoading: false, isPreparingLoading: false }); + } + } + + ThreadView.fields = { + checkedMessages: many2many('mail.message', { + related: 'threadCache.checkedMessages', + }), + /** + * List of component hints. Hints contain information that help + * components make UI/UX decisions based on their UI state. + * For instance, on receiving new messages and the last message + * is visible, it should auto-scroll to this new last message. + * + * Format of a component hint: + * + * { + * type: {string} the name of the component hint. Useful + * for components to dispatch behaviour + * based on its type. + * data: {Object} data related to the component hint. + * For instance, if hint suggests to scroll + * to a certain message, data may contain + * message id. + * } + */ + componentHintList: attr({ + default: [], + }), + composer: many2one('mail.composer', { + related: 'thread.composer', + }), + /** + * Serves as compute dependency. + */ + device: one2one('mail.device', { + related: 'messaging.device', + }), + /** + * Serves as compute dependency. + */ + deviceIsMobile: attr({ + related: 'device.isMobile', + }), + hasComposerFocus: attr({ + related: 'composer.hasFocus', + }), + /** + * States whether `this.threadCache` is currently loading messages. + * + * This field is related to `this.threadCache.isLoading` but with a + * delay on its update to avoid flickering on the UI. + * + * It is computed through `_onThreadCacheIsLoadingChanged` and it should + * otherwise be considered read-only. + */ + isLoading: attr({ + default: false, + }), + /** + * States whether `this` is aware of `this.threadCache` currently + * loading messages, but `this` is not yet ready to display that loading + * on the UI. + * + * This field is computed through `_onThreadCacheIsLoadingChanged` and + * it should otherwise be considered read-only. + * + * @see `this.isLoading` + */ + isPreparingLoading: attr({ + default: false, + }), + /** + * Determines whether `this` should automatically scroll on receiving + * a new message. Detection of new message is done through the component + * hint `message-received`. + */ + hasAutoScrollOnMessageReceived: attr({ + default: true, + }), + /** + * Last message in the context of the currently displayed thread cache. + */ + lastMessage: many2one('mail.message', { + related: 'thread.lastMessage', + }), + /** + * Serves as compute dependency. + */ + lastNonTransientMessage: many2one('mail.message', { + related: 'thread.lastNonTransientMessage', + }), + /** + * Most recent message in this ThreadView that has been shown to the + * current partner in the currently displayed thread cache. + */ + lastVisibleMessage: many2one('mail.message'), + messages: many2many('mail.message', { + related: 'threadCache.messages', + }), + /** + * Serves as compute dependency. + */ + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + nonEmptyMessages: many2many('mail.message', { + related: 'threadCache.nonEmptyMessages', + }), + /** + * Not a real field, used to trigger `_onThreadCacheChanged` when one of + * the dependencies changes. + */ + onThreadCacheChanged: attr({ + compute: '_onThreadCacheChanged', + dependencies: [ + 'threadCache' + ], + }), + /** + * Not a real field, used to trigger `_onThreadCacheIsLoadingChanged` + * when one of the dependencies changes. + * + * @see `this.isLoading` + */ + onThreadCacheIsLoadingChanged: attr({ + compute: '_onThreadCacheIsLoadingChanged', + dependencies: [ + 'threadCache', + 'threadCacheIsLoading', + ], + }), + /** + * Determines the domain to apply when fetching messages for `this.thread`. + */ + stringifiedDomain: attr({ + related: 'threadViewer.stringifiedDomain', + }), + /** + * Determines the keyboard shortcuts that are available to send a message + * from the composer of this thread viewer. + */ + textInputSendShortcuts: attr({ + compute: '_computeTextInputSendShortcuts', + dependencies: [ + 'device', + 'deviceIsMobile', + 'thread', + 'threadMassMailing', + 'threadModel', + ], + }), + /** + * Determines the `mail.thread` currently displayed by `this`. + */ + thread: many2one('mail.thread', { + inverse: 'threadViews', + related: 'threadViewer.thread', + }), + /** + * States the `mail.thread_cache` currently displayed by `this`. + */ + threadCache: many2one('mail.thread_cache', { + inverse: 'threadViews', + related: 'threadViewer.threadCache', + }), + threadCacheInitialScrollHeight: attr({ + compute: '_computeThreadCacheInitialScrollHeight', + dependencies: [ + 'threadCache', + 'threadCacheInitialScrollHeights', + ], + }), + threadCacheInitialScrollPosition: attr({ + compute: '_computeThreadCacheInitialScrollPosition', + dependencies: [ + 'threadCache', + 'threadCacheInitialScrollPositions', + ], + }), + /** + * Serves as compute dependency. + */ + threadCacheIsLoading: attr({ + related: 'threadCache.isLoading', + }), + /** + * List of saved initial scroll heights of thread caches. + */ + threadCacheInitialScrollHeights: attr({ + default: {}, + related: 'threadViewer.threadCacheInitialScrollHeights', + }), + /** + * List of saved initial scroll positions of thread caches. + */ + threadCacheInitialScrollPositions: attr({ + default: {}, + related: 'threadViewer.threadCacheInitialScrollPositions', + }), + /** + * Serves as compute dependency. + */ + threadMassMailing: attr({ + related: 'thread.mass_mailing', + }), + /** + * Serves as compute dependency. + */ + threadModel: attr({ + related: 'thread.model', + }), + /** + * Not a real field, used to trigger `thread.markAsSeen` when one of + * the dependencies changes. + */ + threadShouldBeSetAsSeen: attr({ + compute: '_computeThreadShouldBeSetAsSeen', + dependencies: [ + 'hasComposerFocus', + 'lastMessage', + 'lastNonTransientMessage', + 'lastVisibleMessage', + 'threadCache', + ], + }), + /** + * Determines the `mail.thread_viewer` currently managing `this`. + */ + threadViewer: one2one('mail.thread_viewer', { + inverse: 'threadView', + }), + uncheckedMessages: many2many('mail.message', { + related: 'threadCache.uncheckedMessages', + }), + }; + + ThreadView.modelName = 'mail.thread_view'; + + return ThreadView; +} + +registerNewModel('mail.thread_view', factory); + +}); diff --git a/addons/mail/static/src/models/thread_view/thread_viewer.js b/addons/mail/static/src/models/thread_view/thread_viewer.js new file mode 100644 index 00000000..c78022d4 --- /dev/null +++ b/addons/mail/static/src/models/thread_view/thread_viewer.js @@ -0,0 +1,296 @@ +odoo.define('mail/static/src/models/thread_viewer/thread_viewer.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ThreadViewer extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @param {integer} scrollHeight + * @param {mail.thread_cache} threadCache + */ + saveThreadCacheScrollHeightAsInitial(scrollHeight, threadCache) { + threadCache = threadCache || this.threadCache; + if (!threadCache) { + return; + } + if (this.chatter) { + // Initial scroll height is disabled for chatter because it is + // too complex to handle correctly and less important + // functionally. + return; + } + this.update({ + threadCacheInitialScrollHeights: Object.assign({}, this.threadCacheInitialScrollHeights, { + [threadCache.localId]: scrollHeight, + }), + }); + } + + /** + * @param {integer} scrollTop + * @param {mail.thread_cache} threadCache + */ + saveThreadCacheScrollPositionsAsInitial(scrollTop, threadCache) { + threadCache = threadCache || this.threadCache; + if (!threadCache) { + return; + } + if (this.chatter) { + // Initial scroll position is disabled for chatter because it is + // too complex to handle correctly and less important + // functionally. + return; + } + this.update({ + threadCacheInitialScrollPositions: Object.assign({}, this.threadCacheInitialScrollPositions, { + [threadCache.localId]: scrollTop, + }), + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {boolean} + */ + _computeHasThreadView() { + if (this.chatter) { + return this.chatter.hasThreadView; + } + if (this.chatWindow) { + return this.chatWindow.hasThreadView; + } + if (this.discuss) { + return this.discuss.hasThreadView; + } + return this.hasThreadView; + } + + /** + * @private + * @returns {string} + */ + _computeStringifiedDomain() { + if (this.chatter) { + return '[]'; + } + if (this.chatWindow) { + return '[]'; + } + if (this.discuss) { + return this.discuss.stringifiedDomain; + } + return this.stringifiedDomain; + } + + /** + * @private + * @returns {mail.thread|undefined} + */ + _computeThread() { + if (this.chatter) { + if (!this.chatter.thread) { + return [['unlink']]; + } + return [['link', this.chatter.thread]]; + } + if (this.chatWindow) { + if (!this.chatWindow.thread) { + return [['unlink']]; + } + return [['link', this.chatWindow.thread]]; + } + if (this.discuss) { + if (!this.discuss.thread) { + return [['unlink']]; + } + return [['link', this.discuss.thread]]; + } + return []; + } + + /** + * @private + * @returns {mail.thread_cache|undefined} + */ + _computeThreadCache() { + if (!this.thread) { + return [['unlink']]; + } + return [['link', this.thread.cache(this.stringifiedDomain)]]; + } + + /** + * @private + * @returns {mail.thread_viewer|undefined} + */ + _computeThreadView() { + if (!this.hasThreadView) { + return [['unlink']]; + } + if (this.threadView) { + return []; + } + return [['create']]; + } + + } + + ThreadViewer.fields = { + /** + * States the `mail.chatter` managing `this`. This field is computed + * through the inverse relation and should be considered read-only. + */ + chatter: one2one('mail.chatter', { + inverse: 'threadViewer', + }), + /** + * Serves as compute dependency. + */ + chatterHasThreadView: attr({ + related: 'chatter.hasThreadView', + }), + /** + * Serves as compute dependency. + */ + chatterThread: many2one('mail.thread', { + related: 'chatter.thread', + }), + /** + * States the `mail.chat_window` managing `this`. This field is computed + * through the inverse relation and should be considered read-only. + */ + chatWindow: one2one('mail.chat_window', { + inverse: 'threadViewer', + }), + /** + * Serves as compute dependency. + */ + chatWindowHasThreadView: attr({ + related: 'chatWindow.hasThreadView', + }), + /** + * Serves as compute dependency. + */ + chatWindowThread: many2one('mail.thread', { + related: 'chatWindow.thread', + }), + /** + * States the `mail.discuss` managing `this`. This field is computed + * through the inverse relation and should be considered read-only. + */ + discuss: one2one('mail.discuss', { + inverse: 'threadViewer', + }), + /** + * Serves as compute dependency. + */ + discussHasThreadView: attr({ + related: 'discuss.hasThreadView', + }), + /** + * Serves as compute dependency. + */ + discussStringifiedDomain: attr({ + related: 'discuss.stringifiedDomain', + }), + /** + * Serves as compute dependency. + */ + discussThread: many2one('mail.thread', { + related: 'discuss.thread', + }), + /** + * Determines whether `this.thread` should be displayed. + */ + hasThreadView: attr({ + compute: '_computeHasThreadView', + default: false, + dependencies: [ + 'chatterHasThreadView', + 'chatWindowHasThreadView', + 'discussHasThreadView', + ], + }), + /** + * Determines the domain to apply when fetching messages for `this.thread`. + */ + stringifiedDomain: attr({ + compute: '_computeStringifiedDomain', + default: '[]', + dependencies: [ + 'discussStringifiedDomain', + ], + }), + /** + * Determines the `mail.thread` that should be displayed by `this`. + */ + thread: many2one('mail.thread', { + compute: '_computeThread', + dependencies: [ + 'chatterThread', + 'chatWindowThread', + 'discussThread', + ], + }), + /** + * States the `mail.thread_cache` that should be displayed by `this`. + */ + threadCache: many2one('mail.thread_cache', { + compute: '_computeThreadCache', + dependencies: [ + 'stringifiedDomain', + 'thread', + ], + }), + /** + * Determines the initial scroll height of thread caches, which is the + * scroll height at the time the last scroll position was saved. + * Useful to only restore scroll position when the corresponding height + * is available, otherwise the restore makes no sense. + */ + threadCacheInitialScrollHeights: attr({ + default: {}, + }), + /** + * Determines the initial scroll positions of thread caches. + * Useful to restore scroll position on changing back to this + * thread cache. Note that this is only applied when opening + * the thread cache, because scroll position may change fast so + * save is already throttled. + */ + threadCacheInitialScrollPositions: attr({ + default: {}, + }), + /** + * States the `mail.thread_view` currently displayed and managed by `this`. + */ + threadView: one2one('mail.thread_view', { + compute: '_computeThreadView', + dependencies: [ + 'hasThreadView', + ], + inverse: 'threadViewer', + isCausal: true, + }), + }; + + ThreadViewer.modelName = 'mail.thread_viewer'; + + return ThreadViewer; +} + +registerNewModel('mail.thread_viewer', factory); + +}); diff --git a/addons/mail/static/src/models/user/user.js b/addons/mail/static/src/models/user/user.js new file mode 100644 index 00000000..721b586f --- /dev/null +++ b/addons/mail/static/src/models/user/user.js @@ -0,0 +1,254 @@ +odoo.define('mail/static/src/models/user/user.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class User extends dependencies['mail.model'] { + + /** + * @override + */ + _willDelete() { + if (this.env.messaging) { + if (this === this.env.messaging.currentUser) { + this.env.messaging.update({ currentUser: [['unlink']] }); + } + } + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @returns {Object} + */ + static convertData(data) { + const data2 = {}; + if ('id' in data) { + data2.id = data.id; + } + if ('partner_id' in data) { + if (!data.partner_id) { + data2.partner = [['unlink']]; + } else { + const partnerNameGet = data['partner_id']; + const partnerData = { + display_name: partnerNameGet[1], + id: partnerNameGet[0], + }; + data2.partner = [['insert', partnerData]]; + } + } + return data2; + } + + /** + * Performs the `read` RPC on `res.users`. + * + * @static + * @param {Object} param0 + * @param {Object} param0.context + * @param {string[]} param0.fields + * @param {integer[]} param0.ids + */ + static async performRpcRead({ context, fields, ids }) { + const usersData = await this.env.services.rpc({ + model: 'res.users', + method: 'read', + args: [ids], + kwargs: { + context, + fields, + }, + }, { shadow: true }); + return this.env.models['mail.user'].insert(usersData.map(userData => + this.env.models['mail.user'].convertData(userData) + )); + } + + /** + * Fetches the partner of this user. + */ + async fetchPartner() { + return this.env.models['mail.user'].performRpcRead({ + ids: [this.id], + fields: ['partner_id'], + context: { active_test: false }, + }); + } + + /** + * Gets the chat between this user and the current user. + * + * If a chat is not appropriate, a notification is displayed instead. + * + * @returns {mail.thread|undefined} + */ + async getChat() { + if (!this.partner) { + await this.async(() => this.fetchPartner()); + } + if (!this.partner) { + // This user has been deleted from the server or never existed: + // - Validity of id is not verified at insert. + // - There is no bus notification in case of user delete from + // another tab or by another user. + this.env.services['notification'].notify({ + message: this.env._t("You can only chat with existing users."), + type: 'warning', + }); + return; + } + // in other cases a chat would be valid, find it or try to create it + let chat = this.env.models['mail.thread'].find(thread => + thread.channel_type === 'chat' && + thread.correspondent === this.partner && + thread.model === 'mail.channel' && + thread.public === 'private' + ); + if (!chat ||!chat.isPinned) { + // if chat is not pinned then it has to be pinned client-side + // and server-side, which is a side effect of following rpc + chat = await this.async(() => + this.env.models['mail.thread'].performRpcCreateChat({ + partnerIds: [this.partner.id], + }) + ); + } + if (!chat) { + this.env.services['notification'].notify({ + message: this.env._t("An unexpected error occurred during the creation of the chat."), + type: 'warning', + }); + return; + } + return chat; + } + + /** + * Opens a chat between this user and the current user and returns it. + * + * If a chat is not appropriate, a notification is displayed instead. + * + * @param {Object} [options] forwarded to @see `mail.thread:open()` + * @returns {mail.thread|undefined} + */ + async openChat(options) { + const chat = await this.async(() => this.getChat()); + if (!chat) { + return; + } + await this.async(() => chat.open(options)); + return chat; + } + + /** + * Opens the most appropriate view that is a profile for this user. + * Because user is a rather technical model to allow login, it's the + * partner profile that contains the most useful information. + * + * @override + */ + async openProfile() { + if (!this.partner) { + await this.async(() => this.fetchPartner()); + } + if (!this.partner) { + // This user has been deleted from the server or never existed: + // - Validity of id is not verified at insert. + // - There is no bus notification in case of user delete from + // another tab or by another user. + this.env.services['notification'].notify({ + message: this.env._t("You can only open the profile of existing users."), + type: 'warning', + }); + return; + } + return this.partner.openProfile(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @private + * @returns {string|undefined} + */ + _computeDisplayName() { + return this.display_name || this.partner && this.partner.display_name; + } + + /** + * @private + * @returns {string|undefined} + */ + _computeNameOrDisplayName() { + return this.partner && this.partner.nameOrDisplayName || this.display_name; + } + } + + User.fields = { + id: attr(), + /** + * Determines whether this user is an internal user. An internal user is + * a member of the group `base.group_user`. This is the inverse of the + * `share` field in python. + */ + isInternalUser: attr(), + display_name: attr({ + compute: '_computeDisplayName', + dependencies: [ + 'display_name', + 'partnerDisplayName', + ], + }), + model: attr({ + default: 'res.user', + }), + nameOrDisplayName: attr({ + compute: '_computeNameOrDisplayName', + dependencies: [ + 'display_name', + 'partnerNameOrDisplayName', + ] + }), + partner: one2one('mail.partner', { + inverse: 'user', + }), + /** + * Serves as compute dependency. + */ + partnerDisplayName: attr({ + related: 'partner.display_name', + }), + /** + * Serves as compute dependency. + */ + partnerNameOrDisplayName: attr({ + related: 'partner.nameOrDisplayName', + }), + }; + + User.modelName = 'mail.user'; + + return User; +} + +registerNewModel('mail.user', factory); + +}); diff --git a/addons/mail/static/src/scss/activity_view.scss b/addons/mail/static/src/scss/activity_view.scss new file mode 100644 index 00000000..bbb819eb --- /dev/null +++ b/addons/mail/static/src/scss/activity_view.scss @@ -0,0 +1,132 @@ +.o_activity_view { + height: 100%; + > table { + background-color: white; + thead > tr > th:first-of-type { + min-width: 300px; + } + tbody > tr > td, tfoot > tr > td { + cursor: pointer; + } + } + .o_activity_summary_cell { + background-color: #FFF; + &.planned { + background-color: theme-color('success'); + } + &.overdue { + background-color: theme-color('danger'); + } + &.today { + background-color: theme-color('warning'); + } + .o_kanban_inline_block { + min-height: 42px; + } + .dropdown-toggle { + cursor: pointer; + .o_closest_deadline { + height: 42px; + width: 100%; + color: #FFF; + text-align: center; + line-height: 42px; + } + } + &.o_activity_empty_cell { + > i { + display: none; + } + &:hover { + background-color: #eee; + + > i { + color: gray; + display: block; + } + } + } + .o_activity_btn > .badge { + @include o-position-absolute($bottom: 0, $right: 0); + + &.planned { + color: theme-color('success'); + } + &.overdue { + color: theme-color('danger'); + } + &.today { + color: theme-color('warning'); + } + } + } + + // it contains a kanban card representing the record + .o_activity_record { + display: flex; + flex: 1 1 auto; + align-items: center; + padding: 8px 8px; + cursor: pointer; + + > img { + width: 32px; + max-height: 32px; + margin-right: 16px; + } + + > div { + max-width: 200px; + + .o_text_block { + @include o-text-overflow; + display: block; + } + } + + .o_text_bold { + font-weight: bold; + } + + .o_text_block { + display: block; + } + } + .o_activity_filter_planned { + background-color: mix(theme-color('success'), $o-webclient-background-color, 5%); + } + .o_activity_filter_today { + background-color: mix(theme-color('warning'), $o-webclient-background-color, 5%); + } + .o_activity_filter_overdue { + background-color: mix(theme-color('danger'), $o-webclient-background-color, 5%); + } + .o_record_selector { + color: $o-enterprise-primary-color; + } + .o_activity_type_cell { + padding:10px; + min-width:100px; + .fa-ellipsis-v { + cursor: pointer; + } + + .o_template_element { + white-space: nowrap; + padding:5px; + cursor: pointer; + &:hover { + color: theme-color('success'); + } + } + .o_kanban_counter { + margin: 5px 0 0 0; + > .o_kanban_counter_progress { + width: 100%; + > div.active { + border: 1px solid; + } + } + } + } +} diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss new file mode 100644 index 00000000..b478c0b0 --- /dev/null +++ b/addons/mail/static/src/scss/composer.scss @@ -0,0 +1,161 @@ +@font-face { + font-family: 'emojifont'; + src: local('Segoe UI'), + local('Apple Color Emoji'), + local('Android Emoji'), + local('Noto Color Emoji'), + local('Twitter Color Emoji'), + local('Twitter Color'), + local('EmojiOne Color'), + local('EmojiOne'), + local(EmojiSymbols), + local(Symbola); +} + +// Emoji +.o_mail_emoji { + display: inline-block; + padding: 0; + font-size: 1.3rem; + font-family: emojifont; +} +.o_mail_preview .o_mail_emoji { + font-size: 100%; +} + +@mixin o-viewer-black-btn { + background-color: rgba(black, 0.4); + color: rgba(theme-color('light'), 0.7); + + &:hover { + background-color: rgba(black, 0.6); + color: white; + } + + &.disabled { + color: gray('600'); + background: none; + } +} +.o_modal_fullscreen { + z-index: $o-mail-thread-window-zindex + 1; + + .o_viewer_content { + position: relative; + width: 100%; + height: 100%; + + .o_viewer-header { + @include o-position-absolute(0, 0, $left: 0); + height: 45px; + padding: $grid-gutter-width*0.5; + background-color: rgba(black, 0.8); + z-index: 1; + color: #FFFFFF; + + a { + @include o-hover-text-color(rgba(theme-color('light'), 0.6), white); + } + + .o_close_btn { + @include o-position-absolute(-1px, $grid-gutter-width*0.5); + font-size: $h1-font-size; + font-weight: 300; + } + + .o_image_caption { + bottom: 20%; + position: absolute; + } + } + + .o_loading_img { + @include o-position-absolute($top: 45%, $right: 0, $left: 0); + } + + .o_viewer_img_wrapper { + cursor: pointer; + position: fixed; + width: 100%; + height: 100%; + background-color: rgba(black, 0.7); + + .o_viewer_zoomer { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 45px 0; + + img { + image-orientation: from-image; // Only supported in Firefox + } + + img, video { + cursor :auto; + max-width: 100%; + max-height: 100%; + transition: 0.2s cubic-bezier(0, 0, 0.49, 1.6) 0s, opacity 0.15s; + box-shadow: 0 0 40px black; + } + + .o_viewer_pdf { + width: 80%; + height: 100%; + border: 0px; + box-shadow: 1px 1px 20px 1px #000; + } + + @include media-breakpoint-down(sm) { + .o_viewer_pdf, .o_viewer_text { + width: 100%; + } + } + + .o_viewer_text { + width: 80%; + height: 100%; + border: 0px; + box-shadow: 1px 1px 20px 1px #000; + background-color: white; + } + + .o_viewer_video { + height: 80%; + } + } + } + + .o_viewer_toolbar { + @include o-position-absolute($bottom: $grid-gutter-width*0.5); + width: 100%; + overflow: hidden; + justify-content: center; + border-radius: 4px; + + > .btn-group { + background-color: rgba(black, 0.4); + } + + .o_viewer_toolbar_btn { + @include o-viewer-black-btn; + padding-left: 8px; + padding-right: 8px; + } + } + } + + .arrow { + @include o-position-absolute(50%, $grid-gutter-width*0.5); + border-radius: 100%; + padding: 12px 16px 11px 18px; + @include o-viewer-black-btn; + } + + .arrow-left { + left: $grid-gutter-width*0.5; + right: auto; + padding: 12px 18px 11px 16px; + } +} diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss new file mode 100644 index 00000000..22e5e71c --- /dev/null +++ b/addons/mail/static/src/scss/discuss.scss @@ -0,0 +1,191 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_mail_user_status { + font-size: 1em; + position: relative; + &.o_user_online { + color: $o-enterprise-primary-color; + } + &.o_user_idle { + color: theme-color('warning'); + } + &.fa-stack { + width: 1em; + height: 1em; + line-height: 1em; + } +} + +// ------------------------------------------------------------------ +// Thread preview: shared between discuss (mobile) and systray +// ------------------------------------------------------------------ + +.o_mail_preview { + display: flex; + background-color: theme-color('light'); + color: $o-main-text-color; + cursor: pointer; + overflow: hidden; + position: relative; + &:hover { + background-color: gray('300'); + .o_preview_name { + color: $headings-color; + } + .o_discuss_icon { + opacity: 1; + } + } + &:not(:last-child) { + border-bottom: 1px solid gray('400'); + } + .o_mail_preview_image { + display: flex; + align-items: center; + flex: 0 0 auto; + position: relative; + width: 40px; + height: 40px; + object-fit: cover; + > img { + max-width: 100%; + max-height: 100%; + border-radius: 50%; + object-fit: cover; + } + &.o_mail_preview_app > img { + border-radius: 2px; + } + .o_mail_user_status { + @include o-position-absolute($bottom: 0px, $right: 0px); + } + } + .o_preview_info { + flex: 1 1 100%; + overflow: hidden; + .o_preview_title { + align-items: center; + display: flex; + .o_preview_name { + flex: 0 1 auto; + @include o-text-overflow; + } + .o_mail_activity_action_buttons { + display: flex; + flex: 1 1 auto; + flex-flow: row-reverse wrap; + } + .o_mail_activity_action { + padding-top: 0px; + padding-bottom: 0px; + padding-right: 0px; + } + .o_preview_counter { + flex: 0 1 auto; + } + .o_thread_window_expand { + margin: 0px 6px; + } + .o_last_message_date { + flex: 0 0 auto; + color: $o-main-color-muted; + font-weight: 500; + } + } + .o_last_message_preview { + width: 94%; + max-height: 20px; + color: $o-main-color-muted; + @include o-text-overflow; + } + } + .o_discuss_icon { + opacity: 0; + } + &.o_preview_unread { + background-color: transparent; + &:hover { + background-color: theme-color('light'); + } + .o_preview_info { + .o_preview_title { + .o_preview_name, .o_preview_counter { + font-weight: 700; + } + .o_last_message_date { + color: $o-brand-primary; + } + } + } + } + &.o_systray_activity { + background-color: transparent; + &:hover { + background-color: theme-color('light'); + } + } +} + +@include media-breakpoint-down(sm) { + + .o_main_navbar > ul.o_menu_systray > li .dropdown-menu.show { + border: none; + } + + .o_mail_preview { + padding: $o-mail-chatter-mobile-gap; + + .o_preview_info, .o_last_message_date { + margin-left: $o-mail-chatter-mobile-gap; + } + + .o_preview_name { + font-size: 1.1em; + } + + .o_last_message_date { + font-size: 0.9em; + } + + .o_last_message_preview { + margin-top: $o-mail-chatter-mobile-gap*0.5; + } + } + + .o_mail_mobile_tabs { + display: flex; + box-shadow: 0 0 8px gray('400'); + + .o_mail_mobile_tab { + display: flex; + flex: 1 1 auto; + width: 20%; + flex-flow: column nowrap; + justify-content: space-between; + padding: $o-mail-chatter-mobile-gap $o-mail-chatter-mobile-gap*2; + box-shadow: 1px 0 0 gray('400'); + text-align: center; + + > span { + display: block; + font-weight: 500; + font-size: 10px; + + &.fa { + padding-bottom: $o-mail-chatter-mobile-gap*2; + font-size: 1.3em; + } + } + + &.active > span { + color: $o-brand-primary; + } + } + } +} diff --git a/addons/mail/static/src/scss/emojis.scss b/addons/mail/static/src/scss/emojis.scss new file mode 100644 index 00000000..b2cb71a4 --- /dev/null +++ b/addons/mail/static/src/scss/emojis.scss @@ -0,0 +1,67 @@ +// General variable +$o-mail-emoji-height: 2rem; + +.o_mail_add_emoji { + float: right; + margin-bottom: 1rem; + .dropdown-menu { + .o_mail_emoji { + cursor: pointer; + padding: 2px; + width: $o-mail-emoji-height; + height: $o-mail-emoji-height; + @include hover-focus() { + background-color: grey('100'); + } + } + } +} + +.o_form_view { + // Emojis widgets should hide the emoji dropdown button when the field is invisible. + // This is necessary because the button is added *after* the main element (and not inside) + // (see '_attachEmojisDropdown' for more details) + .o_invisible_modifier + .o_mail_add_emoji{ + display: none !important; + } +} + +.o_mail_emojis_dropdown { + height: $o-mail-emoji-height; + width: 40px; + float: right; + bottom: 33px; + margin-bottom: -$o-mail-emoji-height; + + * { + outline: none!important; + box-shadow: none!important; + } + + .dropdown-toggle:after { + display: none; + } +} + +.o_mail_emojis_dropdown_translation { + // if the button is added to a text field with a button "language" + // add margin-right, so the emojis button is placed on the left of the + // language button + margin-right: 20px; +} + +.o_mail_emojis_dropdown_textarea{ + bottom: 40px; +} + + +.o_xxs_form_view { + .o_mail_emojis_dropdown { + bottom: 50px; + } + .o_mail_add_emoji { + .dropdown-menu { + max-width: 320px; + } + } +} diff --git a/addons/mail/static/src/scss/kanban_view.scss b/addons/mail/static/src/scss/kanban_view.scss new file mode 100644 index 00000000..02e11eb1 --- /dev/null +++ b/addons/mail/static/src/scss/kanban_view.scss @@ -0,0 +1,64 @@ +$o-kanban-attachement-image-size: 80px; + +.o_kanban_view { + + .o_kanban_record.o_kanban_attachment { + padding: 0; + + .o_kanban_image { + width: $o-kanban-attachement-image-size; + + + div { + padding-left: $o-kanban-attachement-image-size + $o-kanban-inside-hgutter; + @include media-breakpoint-down(sm) { + padding-left: $o-kanban-attachement-image-size + $o-kanban-inside-hgutter-mobile; + } + } + + .o_kanban_image_wrapper { + min-height: $o-kanban-attachement-image-size; + display: flex; + align-items: center; + justify-content: center; + } + + .o_attachment_image { + @include size($o-kanban-attachement-image-size); + } + + .o_image { + @include size($o-kanban-attachement-image-size*0.7); + } + } + + .o_kanban_details { + .o_kanban_details_wrapper { + display: flex; + flex-direction: column; + min-height: $o-kanban-attachement-image-size; + padding: $o-kanban-inside-vgutter $o-kanban-inside-hgutter; + + .o_kanban_record_title { + margin-bottom: $o-kanban-inside-vgutter*0.5; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 95%; + } + + .o_kanban_record_body { + flex: 1 1 auto; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + font-size: smaller; + } + + .oe_kanban_avatar { + border-radius: 4px; + border: 1px solid $component-active-color; + } + } + } + } +} diff --git a/addons/mail/static/src/scss/mail_activity.scss b/addons/mail/static/src/scss/mail_activity.scss new file mode 100644 index 00000000..2b316232 --- /dev/null +++ b/addons/mail/static/src/scss/mail_activity.scss @@ -0,0 +1,242 @@ +/* Common */ +.o_mail_activity { + &.o_field_widget { + display: block; + } + + .o_thread_date_separator.o_border_dashed { + border-bottom-style: dashed; + + &[data-toggle="collapse"] { + cursor: pointer; + + .o_chatter_planned_activities_summary { + display: none; + } + + &.collapsed { + margin-bottom: 0; + transition: margin 0.8s ease 0s; + + .o_chatter_planned_activities_summary { + display: inline-block; + + span { + padding: 0 5px; + border-radius: 100%; + font-size: 11px; + } + } + + i.fa-caret-down:before { + content: '\f0da'; + } + } + } + } + + #o_chatter_planned_activities { + .o_thread_message { + .o_thread_message_sidebar { + .o_avatar_stack { + position: relative; + text-align: left; + margin-bottom: 8px; + + img { + width: 31px; + height: 31px; + object-fit: cover; + } + + .o_avatar_icon { + @include o-position-absolute($right: -5px, $bottom: -5px); + width: 25px; + height: 25px; + object-fit: cover; + padding: 6px 5px; + text-align: center; + line-height: 1.2; + color: white; + border-radius: 100%; + border: 2px solid white; + } + } + } + + .o_mail_info { + .o_activity_summary { + @include o-text-overflow; + max-width: 290px; + vertical-align: middle; + } + .o_activity_info { + vertical-align: baseline; + padding: 4px 6px; + background: theme-color('light'); + border-radius: 2px 2px 0 0; + @include o-hover-opacity(1, 1); + + &.collapsed { + @include o-hover-opacity(0.5, 1); + background: transparent; + } + } + } + + .o_thread_message_collapse .dl-horizontal.card { + display: inline-block; + margin-bottom: 0; + + dt { + max-width: 80px; + } + dd { + margin-left: 95px; + } + } + + .o_thread_message_note { + margin: 2px 0 5px; + padding: 0px; + } + .o_thread_message_warning { + margin: 2px 0 5px; + } + + .o_activity_template_preview,.o_activity_template_send { + font-weight: bold; + color: $o-brand-primary; + cursor: pointer; + &:hover:not(.active) { + color: darken($o-brand-primary, 15%); + border-color: darken($o-brand-primary, 15%); + } + } + + .o_thread_message_tools { + .o_activity_link { + padding: 0 $input-btn-padding-x; + } + .o_activity_done { + padding-left: 0; + } + } + } + } + + .o_activity_color_default { + color: #dddddd; + } + + .o_activity_color_planned { + color: darken(theme-color('success'), 10%); + } + .o_activity_color_overdue { + color: darken(theme-color('danger'), 10%); + } + .o_activity_color_today { + color: darken(theme-color('warning'), 10%); + } +} + +/* Feedback popover (form view) */ +.o_mail_activity_feedback { + max-width: 410px; + outline: none; + + textarea { + min-width: 250px; + } +} + +/* list_activity widget */ +.o_list_view { + .o_list_table tbody > tr { + > td.o_data_cell.o_list_activity_cell { + overflow: visible !important; // allow the activity dropdown to overflow + .o_mail_activity { + display: flex; + max-width: 275px; + .o_activity_btn { + margin-right: 3px; + } + .o_activity_summary { + @include o-text-overflow; + } + } + } + } +} + +/* Kanban View */ +.o_kanban_record{ + .o_kanban_inline_block { + display: inline-block; + } +} + +.o_kanban_record, .o_view_controller{ + .o_mail_activity { + .o_activity_btn { + span.fa { + overflow: visible; + line-height: 1; + vertical-align: middle; + } + } + + div.o_activity { + min-width: 290px; + padding: 0px 0px; + + .o_activity_log_container { + max-height: 300px; + overflow-y: auto; + } + + ul.o_activity_log { + color: $body-color; + + li { + .o_activity_title_entry { + display: flex; + align-items: baseline; + max-width: 275px; + .o_activity_summary { + @include o-text-overflow; + } + } + + .o_edit_button { + opacity: 0.5; + } + + &:hover .o_edit_button{ + opacity: 1; + } + + .o_activity_link_kanban { + font-size: 1.5em; + @include o-hover-text-color($text-muted, theme-color('success')); + @include o-hover-opacity(0.5, 1); + } + + &.o_activity_selected { + border-bottom: 0; + } + + &.o_activity_form > div { + padding-top: 0.5em; + padding-bottom: 0.7em; + } + } + } + + .o_no_activity { + padding: 10px; + cursor: initial; + } + } + } +} diff --git a/addons/mail/static/src/scss/many2one_avatar_user.scss b/addons/mail/static/src/scss/many2one_avatar_user.scss new file mode 100644 index 00000000..cd3fdd38 --- /dev/null +++ b/addons/mail/static/src/scss/many2one_avatar_user.scss @@ -0,0 +1,6 @@ +.o_field_many2one_avatar.o_clickable_m2o_avatar { + .o_m2o_avatar:hover { + cursor: pointer; + filter: brightness(0.8); + } +} diff --git a/addons/mail/static/src/scss/systray.scss b/addons/mail/static/src/scss/systray.scss new file mode 100644 index 00000000..e198424e --- /dev/null +++ b/addons/mail/static/src/scss/systray.scss @@ -0,0 +1,137 @@ +// Systray icon and dropdown +.o_mail_systray_item { + > a { + > i { + font-size: larger; + } + } + &.o_no_notification > a { + @include o-mail-systray-no-notification-style(); + + .o_notification_counter { + display: none; + } + } + &.show .o_mail_systray_dropdown { + display: flex; + flex-flow: column nowrap; + } + .o_notification_counter { + margin-top: -0.8rem; + margin-right: 0; + margin-left: -0.6rem; + background: $o-enterprise-primary-color; + color: white; + vertical-align: super; + font-size: 0.7em; + } + .o_mail_systray_dropdown { + direction: ltr; + width: 350px; + padding: 0; + + .o_spinner { + display: flex; + align-items: center; + justify-content: center; + color: $o-main-text-color; + height: 50px; + } + + .o_mail_systray_dropdown_top { + display: flex; + flex: 0 0 auto; + justify-content: space-between; + border-bottom: 1px solid gray('400'); + box-shadow: 0 0 2px gray('400'); + .o_filter_button, .o_new_message { + padding: 5px; + } + .o_filter_button { + color: $o-main-color-muted; + &:hover, &.active { + color: $o-brand-primary; + } + &.active { + cursor: default; + font-weight: bold; + } + } + } + + .o_mail_systray_dropdown_items { + flex: 0 1 auto; + max-height: 400px; + min-height: 50px; + overflow-y: auto; + + @include media-breakpoint-up(md) { + .o_mail_preview { + min-height: 50px; + padding: 5px; + .o_mail_preview_image .fa-circle-o { + display: none; + } + .o_preview_info { + margin-left: 10px; + .o_preview_title { + .o_last_message_date { + padding-top: 2px; + font-size: x-small; + margin-left: 10px; + } + } + } + } + } + } + .o_activity_filter_button { + padding: 2px; + } + .o_no_activity { + cursor: initial; + align-items: center; + color: grey; + opacity: 0.5; + padding: 3px; + } + } +} + +.o_no_thread_window .o_mail_systray_dropdown .o_new_message { + display: none; // hide 'new message' button if chat windows are disabled +} + +// Mobile rules +// Goal: mock the design of Discuss in mobile +@include media-breakpoint-down(sm) { + .o_mail_systray_item { + .o_notification_counter { + top: 10%; + } + .o_mail_systray_dropdown { + position: relative; + .o_mail_systray_dropdown_top { + padding: 5px; + } + .o_mail_systray_mobile_header { + padding: 5px; + height: 44px; + border-bottom: 1px solid #ebebeb; + box-shadow: 0 0 2px gray('400'); + } + .o_mail_systray_dropdown_items { + max-height: none; + padding-bottom: 52px; // leave space for tabs + } + .o_mail_mobile_tabs { + position: fixed; + bottom: 0px; + left: 0px; + right: 0px; + background-color: white; + color: $o-main-text-color; + } + } + } +} diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss new file mode 100644 index 00000000..070adb6d --- /dev/null +++ b/addons/mail/static/src/scss/thread.scss @@ -0,0 +1,173 @@ +.o_mail_activity { + + .o_thread_date_separator { + margin-top: 15px; + margin-bottom: 30px; + @include media-breakpoint-down(sm) { + margin-top: 0px; + margin-bottom: 15px; + } + border-bottom: 1px solid gray('400'); + text-align: center; + + .o_thread_date { + position: relative; + top: 10px; + margin: 0 auto; + padding: 0 10px; + font-weight: bold; + background: white; + } + } + + .o_thread_message { + display: flex; + padding: 4px $o-horizontal-padding; + margin-bottom: 0px; + + .o_thread_message_sidebar { + flex: 0 0 $o-mail-thread-avatar-size; + margin-right: 10px; + margin-top: 2px; + text-align: center; + font-size: smaller; + .o_thread_message_sidebar_image { + position: relative; + height: $o-mail-thread-avatar-size; + + .o_updatable_im_status { + width: $o-mail-thread-avatar-size; + } + .o_mail_user_status { + position: absolute; + bottom: 0; + right: 0; + + &.fa-circle-o { + display: none; + } + } + } + + @include media-breakpoint-down(sm) { + margin-top: 4px; + font-size: x-small; + } + + .o_thread_message_avatar { + width: $o-mail-thread-avatar-size; + height: $o-mail-thread-avatar-size; + object-fit: cover; + } + } + .o_thread_icon { + cursor: pointer; + opacity: 0; + } + + &:hover { + .o_thread_icon { + display: inline-block; + opacity: $o-mail-thread-icon-opacity; + &:hover { + opacity: 1; + } + } + } + + .o_mail_redirect { + cursor: pointer; + } + + .o_thread_message_core { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + word-wrap: break-word; + .o_thread_message_content > pre { + white-space: pre-wrap; + word-break: break-word; + } + + .o_mail_note_title { + margin-top: 9px; + } + + .o_mail_subject { + font-style: italic; + } + + .o_mail_notification { + font-style: italic; + color: gray; + } + + [summary~=o_mail_notification] { // name conflicts with channel notifications, but is odoo notification buttons to hide in chatter if present + display: none; + } + + p { + margin: 0 0 9px; // Required by the old design to override a general rule on p's + &:last-child { + margin-bottom: 0; + } + } + a { + display: inline-block; + word-break: break-all; + } + :not(.o_image_box) > img { + max-width: 100%; + height: auto; + } + + .o_mail_body_long { + display: none; + } + + .o_mail_info { + margin-bottom: 2px; + + strong { + color: $headings-color; + } + } + + .o_thread_message_needaction, .o_thread_message_reply { + padding: 4px; + } + } + } + .o_thread_title { + margin-top: 20px; + margin-bottom: 20px; + font-weight: bold; + font-size: 125%; + + &.o_neutral_face_icon:before { + @extend %o-nocontent-init-image; + @include size(120px, 140px); + background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center; + } + } + + .o_mail_no_content { + @include o-position-absolute(30%, 0, 0, 0); + text-align: center; + font-size: 115%; + } + + .o_thread_message .o_thread_message_core .o_mail_read_more { + display: block; + } +} + +.o_web_client .popover .o_thread_tooltip_icon { + min-width: 1rem; +} + +.o_web_client.o_touch_device { + .o_mail_thread .o_thread_icon { + opacity: $o-mail-thread-icon-opacity; + } +} diff --git a/addons/mail/static/src/scss/variables.scss b/addons/mail/static/src/scss/variables.scss new file mode 100644 index 00000000..051a761d --- /dev/null +++ b/addons/mail/static/src/scss/variables.scss @@ -0,0 +1,19 @@ +$o-mail-thread-avatar-size: 36px !default; +$o-mail-thread-icon-opacity: 0.6 !default; +$o-mail-thread-side-date-opacity: 0.6 !default; +$o-mail-thread-window-bg: #FAFAFA !default; +$o-mail-thread-window-width: 325px !default; +$o-mail-chatter-gap: 10px !default; +$o-mail-chatter-mobile-gap: 2% !default; +$o-mail-chat-header-height: 46px !default; +$o-mail-attachment-image-size: 100px !default; +$o-mail-sidebar-icon-opacity: 0.7 !default; +$o-mail-chat-sidebar-width: 250px !default; +$o-mail-partner-avatar-size: 24px !default; +// Needed because $border-radius variations are all set to 0 in enterprise. +$o-mail-rounded-rectangle-border-radius-sm: .2rem !default; +$o-mail-rounded-rectangle-border-radius-lg: 3 * $o-mail-rounded-rectangle-border-radius-sm !default; + +@mixin o-mail-systray-no-notification-style { + opacity: 0.5; +} diff --git a/addons/mail/static/src/services/chat_window_service/chat_window_service.js b/addons/mail/static/src/services/chat_window_service/chat_window_service.js new file mode 100644 index 00000000..8a11c202 --- /dev/null +++ b/addons/mail/static/src/services/chat_window_service/chat_window_service.js @@ -0,0 +1,104 @@ +odoo.define('mail/static/src/services/chat_window_service/chat_window_service.js', function (require) { +'use strict'; + +const components = { + ChatWindowManager: require('mail/static/src/components/chat_window_manager/chat_window_manager.js'), +}; + +const AbstractService = require('web.AbstractService'); +const { bus, serviceRegistry } = require('web.core'); + +const ChatWindowService = AbstractService.extend({ + /** + * @override {web.AbstractService} + */ + start() { + this._super(...arguments); + this._webClientReady = false; + this._listenHomeMenu(); + }, + /** + * @private + */ + destroy() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Node} + */ + _getParentNode() { + return document.querySelector('body'); + }, + /** + * @private + */ + _listenHomeMenu() { + bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this)); + bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this)); + bus.on('web_client_ready', this, this._onWebClientReady.bind(this)); + }, + /** + * @private + */ + async _mount() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + const ChatWindowManagerComponent = components.ChatWindowManager; + this.component = new ChatWindowManagerComponent(null); + const parentNode = this._getParentNode(); + await this.component.mount(parentNode); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + async _onHideHomeMenu() { + if (!this._webClientReady) { + return; + } + if (document.querySelector('.o_ChatWindowManager')) { + return; + } + await this._mount(); + }, + /** + * @private + */ + async _onShowHomeMenu() { + if (!this._webClientReady) { + return; + } + if (document.querySelector('.o_ChatWindowManager')) { + return; + } + await this._mount(); + }, + /** + * @private + */ + async _onWebClientReady() { + await this._mount(); + this._webClientReady = true; + }, +}); + +serviceRegistry.add('chat_window', ChatWindowService); + +return ChatWindowService; + +}); diff --git a/addons/mail/static/src/services/dialog_service/dialog_service.js b/addons/mail/static/src/services/dialog_service/dialog_service.js new file mode 100644 index 00000000..88762a29 --- /dev/null +++ b/addons/mail/static/src/services/dialog_service/dialog_service.js @@ -0,0 +1,101 @@ +odoo.define('mail/static/src/services/dialog_service/dialog_service.js', function (require) { +'use strict'; + +const components = { + DialogManager: require('mail/static/src/components/dialog_manager/dialog_manager.js'), +}; + +const AbstractService = require('web.AbstractService'); +const { bus, serviceRegistry } = require('web.core'); + +const DialogService = AbstractService.extend({ + /** + * @override {web.AbstractService} + */ + start() { + this._super(...arguments); + this._webClientReady = false; + this._listenHomeMenu(); + }, + /** + * @private + */ + destroy() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Node} + */ + _getParentNode() { + return document.querySelector('body'); + }, + /** + * @private + */ + _listenHomeMenu() { + bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this)); + bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this)); + bus.on('web_client_ready', this, this._onWebClientReady.bind(this)); + }, + /** + * @private + */ + async _mount() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + const DialogManagerComponent = components.DialogManager; + this.component = new DialogManagerComponent(null); + const parentNode = this._getParentNode(); + await this.component.mount(parentNode); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + async _onHideHomeMenu() { + if (!this._webClientReady) { + return; + } + if (document.querySelector('.o_DialogManager')) { + return; + } + await this._mount(); + }, + async _onShowHomeMenu() { + if (!this._webClientReady) { + return; + } + if (document.querySelector('.o_DialogManager')) { + return; + } + await this._mount(); + }, + /** + * @private + */ + async _onWebClientReady() { + await this._mount(); + this._webClientReady = true; + } +}); + +serviceRegistry.add('dialog', DialogService); + +return DialogService; + +}); diff --git a/addons/mail/static/src/utils/deferred/deferred.js b/addons/mail/static/src/utils/deferred/deferred.js new file mode 100644 index 00000000..f96696fb --- /dev/null +++ b/addons/mail/static/src/utils/deferred/deferred.js @@ -0,0 +1,21 @@ +odoo.define('mail/static/src/utils/deferred/deferred.js', function (require) { +'use strict'; + +/** + * @returns {Deferred} + */ +function makeDeferred() { + let resolve; + let reject; + const prom = new Promise(function (res, rej) { + resolve = res.bind(this); + reject = rej.bind(this); + }); + prom.resolve = (...args) => resolve(...args); + prom.reject = (...args) => reject(...args); + return prom; +} + +return { makeDeferred }; + +}); diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js new file mode 100644 index 00000000..be15afe7 --- /dev/null +++ b/addons/mail/static/src/utils/test_utils.js @@ -0,0 +1,767 @@ +odoo.define('mail/static/src/utils/test_utils.js', function (require) { +'use strict'; + +const BusService = require('bus.BusService'); + +const { + addMessagingToEnv, + addTimeControlToEnv, +} = require('mail/static/src/env/test_env.js'); +const ModelManager = require('mail/static/src/model/model_manager.js'); +const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js'); +const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); +const DiscussWidget = require('mail/static/src/widgets/discuss/discuss.js'); +const MessagingMenuWidget = require('mail/static/src/widgets/messaging_menu/messaging_menu.js'); +const MockModels = require('mail/static/tests/helpers/mock_models.js'); + +const AbstractStorageService = require('web.AbstractStorageService'); +const NotificationService = require('web.NotificationService'); +const RamStorage = require('web.RamStorage'); +const { + createActionManager, + createView, + makeTestPromise, + mock: { + addMockEnvironment, + patch: legacyPatch, + unpatch: legacyUnpatch, + }, +} = require('web.test_utils'); +const Widget = require('web.Widget'); + +const { Component } = owl; + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * Create a fake object 'dataTransfer', linked to some files, + * which is passed to drag and drop events. + * + * @param {Object[]} files + * @returns {Object} + */ +function _createFakeDataTransfer(files) { + return { + dropEffect: 'all', + effectAllowed: 'all', + files, + items: [], + types: ['Files'], + }; +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useChatWindow(callbacks) { + const { + mount: prevMount, + destroy: prevDestroy, + } = callbacks; + return Object.assign({}, callbacks, { + mount: prevMount.concat(async () => { + // trigger mounting of chat window manager + await Component.env.services['chat_window']._onWebClientReady(); + }), + destroy: prevDestroy.concat(() => { + Component.env.services['chat_window'].destroy(); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useDialog(callbacks) { + const { + mount: prevMount, + destroy: prevDestroy, + } = callbacks; + return Object.assign({}, callbacks, { + mount: prevMount.concat(async () => { + // trigger mounting of dialog manager + await Component.env.services['dialog']._onWebClientReady(); + }), + destroy: prevDestroy.concat(() => { + Component.env.services['dialog'].destroy(); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @return {Object} update callbacks + */ +function _useDiscuss(callbacks) { + const { + init: prevInit, + mount: prevMount, + return: prevReturn, + } = callbacks; + let discussWidget; + const state = { + autoOpenDiscuss: false, + discussData: {}, + }; + return Object.assign({}, callbacks, { + init: prevInit.concat(params => { + const { + autoOpenDiscuss = state.autoOpenDiscuss, + discuss: discussData = state.discussData + } = params; + Object.assign(state, { autoOpenDiscuss, discussData }); + delete params.autoOpenDiscuss; + delete params.discuss; + }), + mount: prevMount.concat(async params => { + const { selector, widget } = params; + DiscussWidget.prototype._pushStateActionManager = () => {}; + discussWidget = new DiscussWidget(widget, state.discussData); + await discussWidget.appendTo($(selector)); + if (state.autoOpenDiscuss) { + await discussWidget.on_attach_callback(); + } + }), + return: prevReturn.concat(result => { + Object.assign(result, { discussWidget }); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useMessagingMenu(callbacks) { + const { + mount: prevMount, + return: prevReturn, + } = callbacks; + let messagingMenuWidget; + return Object.assign({}, callbacks, { + mount: prevMount.concat(async ({ selector, widget }) => { + messagingMenuWidget = new MessagingMenuWidget(widget, {}); + await messagingMenuWidget.appendTo($(selector)); + await messagingMenuWidget.on_attach_callback(); + }), + return: prevReturn.concat(result => { + Object.assign(result, { messagingMenuWidget }); + }), + }); +} + +//------------------------------------------------------------------------------ +// Public: rendering timers +//------------------------------------------------------------------------------ + +/** + * Returns a promise resolved at the next animation frame. + * + * @returns {Promise} + */ +function nextAnimationFrame() { + const requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame; + return new Promise(function (resolve) { + setTimeout(() => requestAnimationFrame(() => resolve())); + }); +} + +/** + * Returns a promise resolved the next time OWL stops rendering. + * + * @param {function} func function which, when called, is + * expected to trigger OWL render(s). + * @param {number} [timeoutDelay=5000] in ms + * @returns {Promise} + */ +const afterNextRender = (function () { + const stop = owl.Component.scheduler.stop; + const stopPromises = []; + + owl.Component.scheduler.stop = function () { + const wasRunning = this.isRunning; + stop.call(this); + if (wasRunning) { + while (stopPromises.length) { + stopPromises.pop().resolve(); + } + } + }; + + async function afterNextRender(func, timeoutDelay = 5000) { + // Define the potential errors outside of the promise to get a proper + // trace if they happen. + const startError = new Error("Timeout: the render didn't start."); + const stopError = new Error("Timeout: the render didn't stop."); + // Set up the timeout to reject if no render happens. + let timeoutNoRender; + const timeoutProm = new Promise((resolve, reject) => { + timeoutNoRender = setTimeout(() => { + let error = startError; + if (owl.Component.scheduler.isRunning) { + error = stopError; + } + console.error(error); + reject(error); + }, timeoutDelay); + }); + // Set up the promise to resolve if a render happens. + const prom = makeTestPromise(); + stopPromises.push(prom); + // Start the function expected to trigger a render after the promise + // has been registered to not miss any potential render. + const funcRes = func(); + // Make them race (first to resolve/reject wins). + await Promise.race([prom, timeoutProm]); + clearTimeout(timeoutNoRender); + // Wait the end of the function to ensure all potential effects are + // taken into account during the following verification step. + await funcRes; + // Wait one more frame to make sure no new render has been queued. + await nextAnimationFrame(); + if (owl.Component.scheduler.isRunning) { + await afterNextRender(() => {}, timeoutDelay); + } + } + + return afterNextRender; +})(); + + +//------------------------------------------------------------------------------ +// Public: test lifecycle +//------------------------------------------------------------------------------ + +function beforeEach(self) { + const data = MockModels.generateData(); + + data.partnerRootId = 2; + data['res.partner'].records.push({ + active: false, + display_name: "OdooBot", + id: data.partnerRootId, + }); + + data.currentPartnerId = 3; + data['res.partner'].records.push({ + display_name: "Your Company, Mitchell Admin", + id: data.currentPartnerId, + name: "Mitchell Admin", + }); + data.currentUserId = 2; + data['res.users'].records.push({ + display_name: "Your Company, Mitchell Admin", + id: data.currentUserId, + name: "Mitchell Admin", + partner_id: data.currentPartnerId, + }); + + data.publicPartnerId = 4; + data['res.partner'].records.push({ + active: false, + display_name: "Public user", + id: data.publicPartnerId, + }); + data.publicUserId = 3; + data['res.users'].records.push({ + active: false, + display_name: "Public user", + id: data.publicUserId, + name: "Public user", + partner_id: data.publicPartnerId, + }); + + const originals = { + '_.debounce': _.debounce, + '_.throttle': _.throttle, + }; + + (function patch() { + // patch _.debounce and _.throttle to be fast and synchronous + _.debounce = _.identity; + _.throttle = _.identity; + })(); + + function unpatch() { + _.debounce = originals['_.debounce']; + _.throttle = originals['_.throttle']; + } + + Object.assign(self, { + components: [], + data, + unpatch, + widget: undefined + }); +} + +function afterEach(self) { + if (self.env) { + self.env.bus.off('hide_home_menu', null); + self.env.bus.off('show_home_menu', null); + self.env.bus.off('will_hide_home_menu', null); + self.env.bus.off('will_show_home_menu', null); + } + // The components must be destroyed before the widget, because the + // widget might destroy the models before destroying the components, + // and the components might still rely on messaging (or other) record(s). + while (self.components.length > 0) { + const component = self.components.pop(); + component.destroy(); + } + if (self.widget) { + self.widget.destroy(); + self.widget = undefined; + } + self.env = undefined; + self.unpatch(); +} + +/** + * Creates and returns a new root Component with the given props and mounts it + * on target. + * Assumes that self.env is set to the correct value. + * Components created this way are automatically registered for clean up after + * the test, which will happen when `afterEach` is called. + * + * @param {Object} self the current QUnit instance + * @param {Class} Component the component class to create + * @param {Object} param2 + * @param {Object} [param2.props={}] forwarded to component constructor + * @param {DOM.Element} param2.target mount target for the component + * @returns {owl.Component} the new component instance + */ +async function createRootComponent(self, Component, { props = {}, target }) { + Component.env = self.env; + const component = new Component(null, props); + delete Component.env; + self.components.push(component); + await afterNextRender(() => component.mount(target)); + return component; +} + +/** + * Main function used to make a mocked environment with mocked messaging env. + * + * @param {Object} [param0={}] + * @param {string} [param0.arch] makes only sense when `param0.hasView` is set: + * the arch to use in createView. + * @param {Object} [param0.archs] + * @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when + * `param0.hasDiscuss` is set: determine whether mounted discuss should be + * open initially. + * @param {boolean} [param0.debug=false] + * @param {Object} [param0.data] makes only sense when `param0.hasView` is set: + * the data to use in createView. + * @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss` + * is set: provide data that is passed to discuss widget (= client action) as + * 2nd positional argument. + * @param {Object} [param0.env={}] + * @param {function} [param0.mockFetch] + * @param {function} [param0.mockRPC] + * @param {boolean} [param0.hasActionManager=false] if set, use + * createActionManager. + * @param {boolean} [param0.hasChatWindow=false] if set, mount chat window + * service. + * @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app. + * @param {boolean} [param0.hasMessagingMenu=false] if set, mount messaging + * menu. + * @param {boolean} [param0.hasTimeControl=false] if set, all flow of time + * with `env.browser.setTimeout` are fully controlled by test itself. + * @see addTimeControlToEnv that adds `advanceTime` function in + * `env.testUtils`. + * @param {boolean} [param0.hasView=false] if set, use createView to create a + * view instead of a generic widget. + * @param {Deferred|Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()] + * Deferred that let tests block messaging creation and simulate resolution. + * Useful for testing working components when messaging is not yet created. + * @param {string} [param0.model] makes only sense when `param0.hasView` is set: + * the model to use in createView. + * @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set: + * the res_id to use in createView. + * @param {Object} [param0.services] + * @param {Object} [param0.session] + * @param {Object} [param0.View] makes only sense when `param0.hasView` is set: + * the View class to use in createView. + * @param {Object} [param0.viewOptions] makes only sense when `param0.hasView` + * is set: the view options to use in createView. + * @param {Object} [param0.waitUntilEvent] + * @param {String} [param0.waitUntilEvent.eventName] + * @param {String} [param0.waitUntilEvent.message] + * @param {function} [param0.waitUntilEvent.predicate] + * @param {integer} [param0.waitUntilEvent.timeoutDelay] + * @param {string} [param0.waitUntilMessagingCondition='initialized'] Determines + * the condition of messaging when this function is resolved. + * Supported values: ['none', 'created', 'initialized']. + * - 'none': the function resolves regardless of whether messaging is created. + * - 'created': the function resolves when messaging is created, but + * regardless of whether messaging is initialized. + * - 'initialized' (default): the function resolves when messaging is + * initialized. + * To guarantee messaging is not created, test should pass a pending deferred + * as param of `messagingBeforeCreationDeferred`. To make sure messaging is + * not initialized, test should mock RPC `mail/init_messaging` and block its + * resolution. + * @param {...Object} [param0.kwargs] + * @throws {Error} in case some provided parameters are wrong, such as + * `waitUntilMessagingCondition`. + * @returns {Object} + */ +async function start(param0 = {}) { + let callbacks = { + init: [], + mount: [], + destroy: [], + return: [], + }; + const { + env: providedEnv, + hasActionManager = false, + hasChatWindow = false, + hasDialog = false, + hasDiscuss = false, + hasMessagingMenu = false, + hasTimeControl = false, + hasView = false, + messagingBeforeCreationDeferred = Promise.resolve(), + waitUntilEvent, + waitUntilMessagingCondition = 'initialized', + } = param0; + if (!['none', 'created', 'initialized'].includes(waitUntilMessagingCondition)) { + throw Error(`Unknown parameter value ${waitUntilMessagingCondition} for 'waitUntilMessaging'.`); + } + delete param0.env; + delete param0.hasActionManager; + delete param0.hasChatWindow; + delete param0.hasDiscuss; + delete param0.hasMessagingMenu; + delete param0.hasTimeControl; + delete param0.hasView; + if (hasChatWindow) { + callbacks = _useChatWindow(callbacks); + } + if (hasDialog) { + callbacks = _useDialog(callbacks); + } + if (hasDiscuss) { + callbacks = _useDiscuss(callbacks); + } + if (hasMessagingMenu) { + callbacks = _useMessagingMenu(callbacks); + } + const { + init: initCallbacks, + mount: mountCallbacks, + destroy: destroyCallbacks, + return: returnCallbacks, + } = callbacks; + const { debug = false } = param0; + initCallbacks.forEach(callback => callback(param0)); + + let env = Object.assign(providedEnv || {}); + env.session = Object.assign( + { + is_bound: Promise.resolve(), + url: s => s, + }, + env.session + ); + env = addMessagingToEnv(env); + if (hasTimeControl) { + env = addTimeControlToEnv(env); + } + + const services = Object.assign({}, { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused() { + return true; + }, + updateOption() {}, + }), + chat_window: ChatWindowService.extend({ + _getParentNode() { + return document.querySelector(debug ? 'body' : '#qunit-fixture'); + }, + _listenHomeMenu: () => {}, + }), + dialog: DialogService.extend({ + _getParentNode() { + return document.querySelector(debug ? 'body' : '#qunit-fixture'); + }, + _listenHomeMenu: () => {}, + }), + local_storage: AbstractStorageService.extend({ storage: new RamStorage() }), + notification: NotificationService.extend(), + }, param0.services); + + const kwargs = Object.assign({}, param0, { + archs: Object.assign({}, { + 'mail.message,false,search': '<search/>' + }, param0.archs), + debug: param0.debug || false, + services: Object.assign({}, services, param0.services), + }, { env }); + let widget; + let mockServer; // only in basic mode + let testEnv; + const selector = debug ? 'body' : '#qunit-fixture'; + if (hasView) { + widget = await createView(kwargs); + legacyPatch(widget, { + destroy() { + destroyCallbacks.forEach(callback => callback({ widget })); + this._super(...arguments); + legacyUnpatch(widget); + if (testEnv) { + testEnv.destroyMessaging(); + } + } + }); + } else if (hasActionManager) { + widget = await createActionManager(kwargs); + legacyPatch(widget, { + destroy() { + destroyCallbacks.forEach(callback => callback({ widget })); + this._super(...arguments); + legacyUnpatch(widget); + if (testEnv) { + testEnv.destroyMessaging(); + } + } + }); + } else { + const Parent = Widget.extend({ do_push_state() {} }); + const parent = new Parent(); + mockServer = await addMockEnvironment(parent, kwargs); + widget = new Widget(parent); + await widget.appendTo($(selector)); + Object.assign(widget, { + destroy() { + delete widget.destroy; + destroyCallbacks.forEach(callback => callback({ widget })); + parent.destroy(); + if (testEnv) { + testEnv.destroyMessaging(); + } + }, + }); + } + + testEnv = Component.env; + + /** + * Components cannot use web.bus, because they cannot use + * EventDispatcherMixin, and webclient cannot easily access env. + * Communication between webclient and components by core.bus + * (usable by webclient) and messagingBus (usable by components), which + * the messaging service acts as mediator since it can easily use both + * kinds of buses. + */ + testEnv.bus.on( + 'hide_home_menu', + null, + () => testEnv.messagingBus.trigger('hide_home_menu') + ); + testEnv.bus.on( + 'show_home_menu', + null, + () => testEnv.messagingBus.trigger('show_home_menu') + ); + testEnv.bus.on( + 'will_hide_home_menu', + null, + () => testEnv.messagingBus.trigger('will_hide_home_menu') + ); + testEnv.bus.on( + 'will_show_home_menu', + null, + () => testEnv.messagingBus.trigger('will_show_home_menu') + ); + + /** + * Returns a promise resolved after the expected event is received. + * + * @param {Object} param0 + * @param {string} param0.eventName event to wait + * @param {function} param0.func function which, when called, is expected to + * trigger the event + * @param {string} [param0.message] assertion message + * @param {function} [param0.predicate] predicate called with event data. + * If not provided, only the event name has to match. + * @param {number} [param0.timeoutDelay=5000] how long to wait at most in ms + * @returns {Promise} + */ + const afterEvent = (async ({ eventName, func, message, predicate, timeoutDelay = 5000 }) => { + // Set up the timeout to reject if the event is not triggered. + let timeoutNoEvent; + const timeoutProm = new Promise((resolve, reject) => { + timeoutNoEvent = setTimeout(() => { + let error = message + ? new Error(message) + : new Error(`Timeout: the event ${eventName} was not triggered.`); + console.error(error); + reject(error); + }, timeoutDelay); + }); + // Set up the promise to resolve if the event is triggered. + const eventProm = new Promise(resolve => { + testEnv.messagingBus.on(eventName, null, data => { + if (!predicate || predicate(data)) { + resolve(); + } + }); + }); + // Start the function expected to trigger the event after the + // promise has been registered to not miss any potential event. + const funcRes = func(); + // Make them race (first to resolve/reject wins). + await Promise.race([eventProm, timeoutProm]); + clearTimeout(timeoutNoEvent); + // If the event is triggered before the end of the async function, + // ensure the function finishes its job before returning. + await funcRes; + }); + + const result = { + afterEvent, + env: testEnv, + mockServer, + widget, + }; + + const start = async () => { + messagingBeforeCreationDeferred.then(async () => { + /** + * Some models require session data, like locale text direction + * (depends on fully loaded translation). + */ + await env.session.is_bound; + + testEnv.modelManager = new ModelManager(testEnv); + testEnv.modelManager.start(); + /** + * Create the messaging singleton record. + */ + testEnv.messaging = testEnv.models['mail.messaging'].create(); + testEnv.messaging.start().then(() => + testEnv.messagingInitializedDeferred.resolve() + ); + testEnv.messagingCreatedPromise.resolve(); + }); + if (waitUntilMessagingCondition === 'created') { + await testEnv.messagingCreatedPromise; + } + if (waitUntilMessagingCondition === 'initialized') { + await testEnv.messagingInitializedDeferred; + } + + if (mountCallbacks.length > 0) { + await afterNextRender(async () => { + await Promise.all(mountCallbacks.map(callback => callback({ selector, widget }))); + }); + } + returnCallbacks.forEach(callback => callback(result)); + }; + if (waitUntilEvent) { + await afterEvent(Object.assign({ func: start }, waitUntilEvent)); + } else { + await start(); + } + return result; +} + +//------------------------------------------------------------------------------ +// Public: file utilities +//------------------------------------------------------------------------------ + +/** + * Drag some files over a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} file must have been create beforehand + * @see testUtils.file.createFile + */ +function dragenterFiles(el, files) { + const ev = new Event('dragenter', { bubbles: true }); + Object.defineProperty(ev, 'dataTransfer', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +/** + * Drop some files on a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} files must have been created beforehand + * @see testUtils.file.createFile + */ +function dropFiles(el, files) { + const ev = new Event('drop', { bubbles: true }); + Object.defineProperty(ev, 'dataTransfer', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +/** + * Paste some files on a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} files must have been created beforehand + * @see testUtils.file.createFile + */ +function pasteFiles(el, files) { + const ev = new Event('paste', { bubbles: true }); + Object.defineProperty(ev, 'clipboardData', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +//------------------------------------------------------------------------------ +// Export +//------------------------------------------------------------------------------ + +return { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + dragenterFiles, + dropFiles, + nextAnimationFrame, + nextTick, + pasteFiles, + start, +}; + +}); diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js new file mode 100644 index 00000000..6b9ff008 --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle.js @@ -0,0 +1,382 @@ +odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); + +/** + * This module define an utility function that enables throttling calls on a + * provided function. Such throttled calls can be canceled, flushed and/or + * cleared: + * + * - cancel: Canceling a throttle function call means that if a function call is + * pending invocation, cancel removes this pending call invocation. It however + * preserves the internal timer of the cooling down phase of this throttle + * function, meaning that any following throttle function call will be pending + * and has to wait for the remaining time of the cooling down phase before + * being invoked. + * + * - flush: Flushing a throttle function call means that if a function call is + * pending invocation, flush immediately terminates the cooling down phase and + * the pending function call is immediately invoked. Flush also works without + * any pending function call: it just terminates the cooling down phase, so + * that a following function call is guaranteed to be immediately called. + * + * - clear: Clearing a throttle function combines canceling and flushing + * together. + */ + +//------------------------------------------------------------------------------ +// Errors +//------------------------------------------------------------------------------ + +/** + * List of internal and external Throttle errors. + * Internal errors are prefixed with `_`. + */ + + /** + * Error when throttle function has been canceled with `.cancel()`. Used to + * let the caller know of throttle function that the call has been canceled, + * which means the inner function will not be called. Usually caller should + * just accept it and kindly treat this error as a polite warning. + */ +class ThrottleCanceledError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = 'ThrottleCanceledError'; + this.throttleId = throttleId; + } +} +/** + * Error when throttle function has been reinvoked again. Used to let know + * caller of throttle function that the call has been canceled and replaced with + * another one, which means the (potentially) following inner function will be + * in the context of another call. Same as for `ThrottleCanceledError`, usually + * caller should just accept it and kindly treat this error as a polite + * warning. + */ +class ThrottleReinvokedError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = 'ThrottleReinvokedError'; + this.throttleId = throttleId; + } +} +/** + * Error when throttle function has been flushed with `.flush()`. Used + * internally to immediately invoke pending inner functions, since a flush means + * the termination of cooling down phase. + * + * @private + */ +class _ThrottleFlushedError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = '_ThrottleFlushedError'; + this.throttleId = throttleId; + } +} + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * This class models the behaviour of the cancelable, flushable and clearable + * throttle version of a provided function. See definitions at the top of this + * file. + */ +class Throttle { + + /** + * @param {Object} env the OWL env + * @param {function} func provided function for making throttled version. + * @param {integer} duration duration of the 'cool down' phase, i.e. + * the minimum duration between the most recent function call that has + * been made and the following function call (of course, assuming no flush + * in-between). + */ + constructor(env, func, duration) { + /** + * Reference to the OWL envirionment. Useful to fine-tune control of + * time flow in tests. + * @see mail/static/src/utils/test_utils.js:start.hasTimeControl + */ + this.env = env; + /** + * Unique id of this throttle function. Useful for the ThrottleError + * management, in order to determine whether these errors come from + * this throttle or from another one (e.g. inner function makes use of + * another throttle). + */ + this.id = _.uniqueId('throttle_'); + /** + * Deferred of current cooling down phase in progress. Defined only when + * there is a cooling down phase in progress. Resolved when cooling down + * phase terminates from timeout, and rejected if flushed. + * + * @see _ThrottleFlushedError for rejection of this deferred. + */ + this._coolingDownDeferred = undefined; + /** + * Duration, in milliseconds, of the cool down phase. + */ + this._duration = duration; + /** + * Inner function to be invoked and throttled. + */ + this._function = func; + /** + * Determines whether the throttle function is currently in cool down + * phase. Cool down phase happens just after inner function has been + * invoked, and during this time any following function call are pending + * and will be invoked only after the end of the cool down phase (except + * if canceled). + */ + this._isCoolingDown = false; + /** + * Deferred of a currently pending invocation to inner function. Defined + * only during a cooling down phase and just after when throttle + * function has been called during this cooling down phase. It is kept + * until cooling down phase ends (either from timeout or flushed + * throttle) or until throttle is canceled (i.e. removes pending invoke + * while keeping cooling down phase live on). + */ + this._pendingInvokeDeferred = undefined; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Cancel any buffered function call while keeping the cooldown phase + * running. + */ + cancel() { + if (!this._isCoolingDown) { + return; + } + if (!this._pendingInvokeDeferred) { + return; + } + this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id)); + } + + /** + * Clear any buffered function call and immediately terminates any cooling + * down phase in progress. + */ + clear() { + this.cancel(); + this.flush(); + } + + /** + * Called when there is a call to the function. This function is throttled, + * so the time it is called depends on whether the "cooldown stage" occurs + * or not: + * + * - no cooldown stage: function is called immediately, and it starts + * the cooldown stage when successful. + * - in cooldown stage: function is called when the cooldown stage has + * ended from timeout. + * + * Note that after the cooldown stage, only the last attempted function + * call will be considered. + * + * @param {...any} args + * @throws {ThrottleReinvokedError|ThrottleCanceledError} + * @returns {any} result of called function, if it's called. + */ + async do(...args) { + if (!this._isCoolingDown) { + return this._invokeFunction(...args); + } + if (this._pendingInvokeDeferred) { + this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id)); + } + try { + this._pendingInvokeDeferred = makeDeferred(); + await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]); + } catch (error) { + if ( + !(error instanceof _ThrottleFlushedError) || + error.throttleId !== this.id + ) { + throw error; + } + } finally { + this._pendingInvokeDeferred = undefined; + } + return this._invokeFunction(...args); + } + + /** + * Flush the internal throttle timer, so that the following function call + * is immediate. For instance, if there is a cooldown stage, it is aborted. + */ + flush() { + if (!this._isCoolingDown) { + return; + } + const coolingDownDeferred = this._coolingDownDeferred; + this._coolingDownDeferred = undefined; + this._isCoolingDown = false; + coolingDownDeferred.reject(new _ThrottleFlushedError(this.id)); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Invoke the inner function of this throttle and starts cooling down phase + * immediately after. + * + * @private + * @param {...any} args + */ + _invokeFunction(...args) { + const res = this._function(...args); + this._startCoolingDown(); + return res; + } + + /** + * Called just when the inner function is being called. Starts the cooling + * down phase, which turn any call to this throttle function as pending + * inner function calls. This will be called after the end of cooling down + * phase (except if canceled). + */ + async _startCoolingDown() { + if (this._coolingDownDeferred) { + throw new Error("Cannot start cooling down if there's already a cooling down in progress."); + } + // Keep local reference of cooling down deferred, because the one stored + // on `this` could be overwritten by another call to this throttle. + const coolingDownDeferred = makeDeferred(); + this._coolingDownDeferred = coolingDownDeferred; + this._isCoolingDown = true; + const cooldownTimeoutId = this.env.browser.setTimeout( + () => coolingDownDeferred.resolve(), + this._duration + ); + let unexpectedError; + try { + await coolingDownDeferred; + } catch (error) { + if ( + !(error instanceof _ThrottleFlushedError) || + error.throttleId !== this.id + ) { + // This branching should never happen. + // Still defined in case of programming error. + unexpectedError = error; + } + } finally { + this.env.browser.clearTimeout(cooldownTimeoutId); + this._coolingDownDeferred = undefined; + this._isCoolingDown = false; + } + if (unexpectedError) { + throw unexpectedError; + } + } + +} + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +/** + * A function that creates a cancelable, flushable and clearable throttle + * version of a provided function. See definitions at the top of this file. + * + * This throttle mechanism allows calling a function at most once during a + * certain period: + * + * - When a function call is made, it enters a 'cooldown' phase, in which any + * attempt to call the function is buffered until the cooldown phase ends. + * - At most 1 function call can be buffered during the cooldown phase, and the + * latest one in this phase will be considered at its end. + * - When a cooldown phase ends, any buffered function call will be performed + * and another cooldown phase will follow up. + * + * @param {Object} env the OWL env + * @param {function} func the function to throttle. + * @param {integer} duration duration, in milliseconds, of the cooling down + * phase of the throttling. + * @param {Object} [param2={}] + * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller + * of throttle function will observe some errors that come from current + * throttle call that has been canceled, such as when throttle function has + * been explicitly canceled with `.cancel()` or when another new throttle call + * has been registered. + * @see ThrottleCanceledError for when a call has been canceled from explicit + * call. + * @see ThrottleReinvokedError for when a call has been canceled from another + * new throttle call has been registered. + * @returns {function} the cancelable, flushable and clearable throttle version + * of the provided function. + */ +function throttle( + env, + func, + duration, + { silentCancelationErrors = true } = {} +) { + const throttleObj = new Throttle(env, func, duration); + const callable = async (...args) => { + try { + // await is important, otherwise errors are not intercepted. + return await throttleObj.do(...args); + } catch (error) { + const isSelfReinvokedError = ( + error instanceof ThrottleReinvokedError && + error.throttleId === throttleObj.id + ); + const isSelfCanceledError = ( + error instanceof ThrottleCanceledError && + error.throttleId === throttleObj.id + ); + + if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) { + // Silently ignore cancelation errors. + // Promise is indefinitely pending for async functions. + return new Promise(() => {}); + } else { + throw error; + } + } + }; + Object.assign(callable, { + cancel: () => throttleObj.cancel(), + clear: () => throttleObj.clear(), + flush: () => throttleObj.flush(), + }); + return callable; +} + +/** + * Make external throttle errors accessible from throttle function. + */ +Object.assign(throttle, { + ThrottleReinvokedError, + ThrottleCanceledError, +}); + + +return throttle; + +}); diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js new file mode 100644 index 00000000..d3e6ad66 --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle_tests.js @@ -0,0 +1,407 @@ +odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); +const throttle = require('mail/static/src/utils/throttle/throttle.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); + +const { ThrottleReinvokedError, ThrottleCanceledError } = throttle; + +QUnit.module('mail', {}, function () { +QUnit.module('utils', {}, function () { +QUnit.module('throttle', {}, function () { +QUnit.module('throttle_tests.js', { + beforeEach() { + beforeEach(this); + this.throttles = []; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + // Important: tests should cleanly intercept cancelation errors that + // may result from this teardown. + for (const t of this.throttles) { + t.clear(); + } + afterEach(this); + }, +}); + +QUnit.test('single call', async function (assert) { + assert.expect(6); + + await this.start({ + hasTimeControl: true, + }); + + let hasInvokedFunc = false; + const throttledFunc = throttle( + this.env, + () => { + hasInvokedFunc = true; + return 'func_result'; + }, + 0 + ); + this.throttles.push(throttledFunc); + + assert.notOk( + hasInvokedFunc, + "func should not have been invoked on immediate throttle initialization" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasInvokedFunc, + "func should not have been invoked from throttle initialization after 0ms" + ); + + throttledFunc().then(res => { + assert.step('throttle_observed_invoke'); + assert.strictEqual( + res, + 'func_result', + "throttle call return should forward result of inner func" + ); + }); + await nextTick(); + assert.ok( + hasInvokedFunc, + "func should have been immediately invoked on first throttle call" + ); + assert.verifySteps( + ['throttle_observed_invoke'], + "throttle should have observed invoked on first throttle call" + ); +}); + +QUnit.test('2nd (throttled) call', async function (assert) { + assert.expect(8); + + await this.start({ + hasTimeControl: true, + }); + + let funcCalledAmount = 0; + const throttledFunc = throttle( + this.env, + () => { + funcCalledAmount++; + return `func_result_${funcCalledAmount}`; + }, + 1000 + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(result => { + assert.step('throttle_observed_invoke_1'); + assert.strictEqual( + result, + 'func_result_1', + "throttle call return should forward result of inner func 1" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc().then(res => { + assert.step('throttle_observed_invoke_2'); + assert.strictEqual( + res, + 'func_result_2', + "throttle call return should forward result of inner func 2" + ); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)" + ); +}); + +QUnit.test('throttled call reinvocation', async function (assert) { + assert.expect(11); + + await this.start({ + hasTimeControl: true, + }); + + let funcCalledAmount = 0; + const throttledFunc = throttle( + this.env, + () => { + funcCalledAmount++; + return `func_result_${funcCalledAmount}`; + }, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(result => { + assert.step('throttle_observed_invoke_1'); + assert.strictEqual( + result, + 'func_result_1', + "throttle call return should forward result of inner func 1" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleReinvokedError, + "Should generate a Throttle reinvoked error (from another throttle function call)" + ); + assert.step('throttle_reinvoked_1'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc() + .then(result => { + assert.step('throttle_observed_invoke_2'); + assert.strictEqual( + result, + 'func_result_2', + "throttle call return should forward result of inner func 2" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_reinvoked_1'], + "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)" + ); +}); + +QUnit.test('flush throttled call', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_2')); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(10); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.flush(); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should have been invoked from 2nd call after flush" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" + ); +}); + +QUnit.test('cancel throttled call', async function (assert) { + assert.expect(10); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleCanceledError, + "Should generate a Throttle canceled error (from `.cancel()`)" + ); + assert.step('throttle_canceled'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.cancel(); + await nextTick(); + assert.verifySteps( + ['throttle_canceled'], + "2nd throttle function call should have been canceled" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + assert.verifySteps( + [], + "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)" + ); +}); + +QUnit.test('clear throttled call', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleCanceledError, + "Should generate a Throttle canceled error (from `.clear()`)" + ); + assert.step('throttle_canceled'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.clear(); + await nextTick(); + assert.verifySteps( + ['throttle_canceled'], + "2nd throttle function call should have been canceled (from `.clear()`)" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/utils/timer/timer.js b/addons/mail/static/src/utils/timer/timer.js new file mode 100644 index 00000000..56d7f58e --- /dev/null +++ b/addons/mail/static/src/utils/timer/timer.js @@ -0,0 +1,165 @@ +odoo.define('mail/static/src/utils/timer/timer.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); + +//------------------------------------------------------------------------------ +// Errors +//------------------------------------------------------------------------------ + +/** + * List of Timer errors. + */ + + /** + * Error when timer has been cleared with `.clear()` or `.reset()`. Used to + * let know caller of timer that the countdown has been aborted, which + * means the inner function will not be called. Usually caller should just + * accept it and kindly treated this error as a polite warning. + */ + class TimerClearedError extends Error { + /** + * @override + */ + constructor(timerId, ...args) { + super(...args); + this.name = 'TimerClearedError'; + this.timerId = timerId; + } +} + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * This class creates a timer which, when times out, calls a function. + * Note that the timer is not started on initialization (@see start method). + */ +class Timer { + + /** + * @param {Object} env the OWL env + * @param {function} onTimeout + * @param {integer} duration + * @param {Object} [param3={}] + * @param {boolean} [param3.silentCancelationErrors=true] if unset, caller + * of timer will observe some errors that come from current timer calls + * that has been cleared with `.clear()` or `.reset()`. + * @see TimerClearedError for when timer has been aborted from `.clear()` + * or `.reset()`. + */ + constructor(env, onTimeout, duration, { silentCancelationErrors = true } = {}) { + this.env = env; + /** + * Determine whether the timer has a pending timeout. + */ + this.isRunning = false; + /** + * Duration, in milliseconds, until timer times out and calls the + * timeout function. + */ + this._duration = duration; + /** + * Determine whether the caller of timer `.start()` and `.reset()` + * should observe cancelation errors from `.clear()` or `.reset()`. + */ + this._hasSilentCancelationErrors = silentCancelationErrors; + /** + * The function that is called when the timer times out. + */ + this._onTimeout = onTimeout; + /** + * Deferred of a currently pending invocation to inner function on + * timeout. + */ + this._timeoutDeferred = undefined; + /** + * Internal reference of `setTimeout()` that is used to invoke function + * when timer times out. Useful to clear it when timer is cleared/reset. + */ + this._timeoutId = undefined; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Clear the timer, which basically sets the state of timer as if it was + * just instantiated, without being started. This function makes sense only + * when this timer is running. + */ + clear() { + this.env.browser.clearTimeout(this._timeoutId); + this.isRunning = false; + if (!this._timeoutDeferred) { + return; + } + this._timeoutDeferred.reject(new TimerClearedError(this.id)); + } + + /** + * Reset the timer, i.e. the pending timeout is refreshed with initial + * duration. This function makes sense only when this timer is running. + */ + async reset() { + this.clear(); + await this.start(); + } + + /** + * Starts the timer, i.e. after a certain duration, it times out and calls + * a function back. This function makes sense only when this timer is not + * yet running. + * + * @throws {Error} in case the timer is already running. + */ + async start() { + if (this.isRunning) { + throw new Error("Cannot start a timer that is currently running."); + } + this.isRunning = true; + const timeoutDeferred = makeDeferred(); + this._timeoutDeferred = timeoutDeferred; + const timeoutId = this.env.browser.setTimeout( + () => { + this.isRunning = false; + timeoutDeferred.resolve(this._onTimeout()); + }, + this._duration + ); + this._timeoutId = timeoutId; + let result; + try { + result = await timeoutDeferred; + } catch (error) { + if ( + !this._hasSilentCancelationErrors || + !(error instanceof TimerClearedError) || + error.timerId !== this.id + ) { + // This branching should never happens. + // Still defined in case of programming error. + throw error; + } + } finally { + this.env.browser.clearTimeout(timeoutId); + this._timeoutDeferred = undefined; + this.isRunning = false; + } + return result; + } + +} + +/** + * Make external timer errors accessible from timer class. + */ +Object.assign(Timer, { + TimerClearedError, +}); + +return Timer; + +}); diff --git a/addons/mail/static/src/utils/timer/timer_tests.js b/addons/mail/static/src/utils/timer/timer_tests.js new file mode 100644 index 00000000..e2d33e91 --- /dev/null +++ b/addons/mail/static/src/utils/timer/timer_tests.js @@ -0,0 +1,427 @@ +odoo.define('mail/static/src/utils/timer/timer_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, nextTick, start } = require('mail/static/src/utils/test_utils.js'); +const Timer = require('mail/static/src/utils/timer/timer.js'); + +const { TimerClearedError } = Timer; + +QUnit.module('mail', {}, function () { +QUnit.module('utils', {}, function () { +QUnit.module('timer', {}, function () { +QUnit.module('timer_tests.js', { + beforeEach() { + beforeEach(this); + this.timers = []; + + this.start = async (params) => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + // Important: tests should cleanly intercept cancelation errors that + // may result from this teardown. + for (const timer of this.timers) { + timer.clear(); + } + afterEach(this); + }, +}); + +QUnit.test('timer does not timeout on initialization', async function (assert) { + assert.expect(3); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + assert.notOk( + hasTimedOut, + "timer should not have timed out on immediate initialization" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out from initialization after 0ms" + ); + + await this.env.testUtils.advanceTime(1000 * 1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out from initialization after 1000s" + ); +}); + +QUnit.test('timer start (duration: 0ms)', async function (assert) { + assert.expect(2); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 0ms" + ); +}); + +QUnit.test('timer start observe termination (duration: 0ms)', async function (assert) { + assert.expect(6); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => { + hasTimedOut = true; + return 'timeout_result'; + }, + 0 + ) + ); + + this.timers[0].start() + .then(result => { + assert.strictEqual( + result, + 'timeout_result', + "value returned by start should be value returned by function on timeout" + ); + assert.step('timeout'); + }); + await nextTick(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + assert.verifySteps( + [], + "timer.start() should not have yet observed timeout" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 0ms" + ); + assert.verifySteps( + ['timeout'], + "timer.start() should have observed timeout after 0ms" + ); +}); + +QUnit.test('timer start (duration: 1000s)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 * 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 0ms" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 1000ms" + ); + + await this.env.testUtils.advanceTime(998 * 1000 + 999); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 9999ms" + ); + + await this.env.testUtils.advanceTime(1); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 10s" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) { + assert.expect(4); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + this.timers[0].clear(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start and clear" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 0ms of clear" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1s of clear" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then clear before timeout (duration: 1000ms)', async function (assert) { + assert.expect(4); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(999); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after 999ms of start" + ); + + this.timers[0].clear(); + await this.env.testUtils.advanceTime(1); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1ms of clear that happens 999ms after start (globally 1s await)" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1001ms after clear (timer fully cleared)" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then reset before timeout (duration: 1000ms)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(999); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 999ms of start" + ); + + this.timers[0].reset(); + await this.env.testUtils.advanceTime(1); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1ms of reset which happens 999ms after start" + ); + + await this.env.testUtils.advanceTime(998); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 999ms of reset" + ); + + await this.env.testUtils.advanceTime(1); + assert.ok( + hasTimedOut, + "timer should not have timed out after 1s of reset" + ); +}); + +QUnit.test('[with cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0, + { silentCancelationErrors: false } + ) + ); + + this.timers[0].start() + .then(() => { + throw new Error("timer.start() should not be resolved (should have been canceled by clear)"); + }) + .catch(error => { + assert.ok( + error instanceof TimerClearedError, + "Should generate a Timer cleared error (from `.clear()`)" + ); + assert.step('timer_cleared'); + }); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + await nextTick(); + assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)"); + + this.timers[0].clear(); + await nextTick(); + assert.verifySteps( + ['timer_cleared'], + "timer.start() should have observed it has been cleared" + ); +}); + +QUnit.test('[with cancelation intercept] timer start then immediate reset (duration: 0ms)', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0, + { silentCancelationErrors: false } + ) + ); + + this.timers[0].start() + .then(() => { + throw new Error("timer.start() should not observe a timeout"); + }) + .catch(error => { + assert.ok(error instanceof TimerClearedError, "Should generate a Timer cleared error (from `.reset()`)"); + assert.step('timer_cleared'); + }); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + await nextTick(); + assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)"); + + this.timers[0].reset() + .then(() => assert.step('timer_reset_timeout')); + await nextTick(); + assert.verifySteps( + ['timer_cleared'], + "timer.start() should have observed it has been cleared" + ); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after reset" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out after reset timeout" + ); + assert.verifySteps( + ['timer_reset_timeout'], + "timer.reset() should have observed it has timed out" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js new file mode 100644 index 00000000..2cfaa531 --- /dev/null +++ b/addons/mail/static/src/utils/utils.js @@ -0,0 +1,193 @@ +odoo.define('mail/static/src/utils/utils.js', function (require) { +'use strict'; + +const { delay } = require('web.concurrency'); +const { + patch: webUtilsPatch, + unaccent, + unpatch: webUtilsUnpatch, +} = require('web.utils'); + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +const classPatchMap = new WeakMap(); +const eventHandledWeakMap = new WeakMap(); + +/** + * Returns the given string after cleaning it. The goal of the clean is to give + * more convenient results when comparing it to potential search results, on + * which the clean should also be called before comparing them. + * + * @param {string} searchTerm + * @returns {string} + */ +function cleanSearchTerm(searchTerm) { + return unaccent(searchTerm.toLowerCase()); +} + +/** + * Executes the provided functions in order, but with a potential delay between + * them if they take too much time. This is done in order to avoid blocking the + * main thread for too long. + * + * @param {function[]} functions + * @param {integer} [maxTimeFrame=100] time (in ms) until a delay is introduced + */ +async function executeGracefully(functions, maxTimeFrame = 100) { + let startDate = new Date(); + for (const func of functions) { + if (new Date() - startDate > maxTimeFrame) { + await new Promise(resolve => setTimeout(resolve)); + startDate = new Date(); + } + await func(); + } +} + +/** + * Returns whether the given event has been handled with the given markName. + * + * @param {Event} ev + * @param {string} markName + * @returns {boolean} + */ +function isEventHandled(ev, markName) { + if (!eventHandledWeakMap.get(ev)) { + return false; + } + return eventHandledWeakMap.get(ev).includes(markName); +} + +/** + * Marks the given event as handled by the given markName. Useful to allow + * handlers in the propagation chain to make a decision based on what has + * already been done. + * + * @param {Event} ev + * @param {string} markName + */ +function markEventHandled(ev, markName) { + if (!eventHandledWeakMap.get(ev)) { + eventHandledWeakMap.set(ev, []); + } + eventHandledWeakMap.get(ev).push(markName); +} + +/** + * Wait a task tick, so that anything in micro-task queue that can be processed + * is processed. + */ +async function nextTick() { + await delay(0); +} + +/** + * Inspired by web.utils:patch utility function + * + * @param {Class} Class + * @param {string} patchName + * @param {Object} patch + * @returns {function} unpatch function + */ +function patchClassMethods(Class, patchName, patch) { + let metadata = classPatchMap.get(Class); + if (!metadata) { + metadata = { + origMethods: {}, + patches: {}, + current: [] + }; + classPatchMap.set(Class, metadata); + } + if (metadata.patches[patchName]) { + throw new Error(`Patch [${patchName}] already exists`); + } + metadata.patches[patchName] = patch; + applyPatch(Class, patch); + metadata.current.push(patchName); + + function applyPatch(Class, patch) { + Object.keys(patch).forEach(function (methodName) { + const method = patch[methodName]; + if (typeof method === "function") { + const original = Class[methodName]; + if (!(methodName in metadata.origMethods)) { + metadata.origMethods[methodName] = original; + } + Class[methodName] = function (...args) { + const previousSuper = this._super; + this._super = original; + const res = method.call(this, ...args); + this._super = previousSuper; + return res; + }; + } + }); + } + + return () => unpatchClassMethods.bind(Class, patchName); +} + +/** + * @param {Class} Class + * @param {string} patchName + * @param {Object} patch + * @returns {function} unpatch function + */ +function patchInstanceMethods(Class, patchName, patch) { + return webUtilsPatch(Class, patchName, patch); +} + +/** + * Inspired by web.utils:unpatch utility function + * + * @param {Class} Class + * @param {string} patchName + */ +function unpatchClassMethods(Class, patchName) { + let metadata = classPatchMap.get(Class); + if (!metadata) { + return; + } + classPatchMap.delete(Class); + + // reset to original + for (let k in metadata.origMethods) { + Class[k] = metadata.origMethods[k]; + } + + // apply other patches + for (let name of metadata.current) { + if (name !== patchName) { + patchClassMethods(Class, name, metadata.patches[name]); + } + } +} + +/** + * @param {Class} Class + * @param {string} patchName + */ +function unpatchInstanceMethods(Class, patchName) { + return webUtilsUnpatch(Class, patchName); +} + +//------------------------------------------------------------------------------ +// Export +//------------------------------------------------------------------------------ + +return { + cleanSearchTerm, + executeGracefully, + isEventHandled, + markEventHandled, + nextTick, + patchClassMethods, + patchInstanceMethods, + unpatchClassMethods, + unpatchInstanceMethods, +}; + +}); diff --git a/addons/mail/static/src/variables.scss b/addons/mail/static/src/variables.scss new file mode 100644 index 00000000..e9bae9ab --- /dev/null +++ b/addons/mail/static/src/variables.scss @@ -0,0 +1,16 @@ +// ----------------------------------------------------------------------------- +// Variables +// ----------------------------------------------------------------------------- + +$o-mail-thread-window-zindex: $zindex-modal + 1 !default; + +$o-mail-chat-window-header-height: 36px !default; +$o-mail-chat-window-header-height-mobile: 46px !default; +$o-mail-discuss-sidebar-active-indicator-margin-right: 10px !default; +$o-mail-discuss-sidebar-active-indicator-width: 3px !default; +$o-mail-discuss-sidebar-scrollbar-width: 15px !default; + +$o-mail-message-sidebar-width: 50px; + +$o-mail-moderation-accept-color: theme-color('success') !default; +$o-mail-moderation-reject-color: theme-color('danger') !default; diff --git a/addons/mail/static/src/widgets/common.xml b/addons/mail/static/src/widgets/common.xml new file mode 100644 index 00000000..9106f96f --- /dev/null +++ b/addons/mail/static/src/widgets/common.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- + AKU FIXME: use mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js component instead + @param {string} status + --> + <t t-name="mail.widgets.UserStatus"> + <span> + <t t-if="status == 'online'"> + <i class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/> + </t> + <t t-if="status == 'away'"> + <i class="fa fa-circle o_mail_user_status o_user_idle" title="Idle" role="img" aria-label="User is idle"/> + </t> + <t t-if="status == 'offline'"> + <i class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/> + </t> + <t t-if="status == 'bot'"> + <i class="o_mail_user_status o_user_online fa fa-heart" title="Bot" role="img" aria-label="User is a bot"/> + </t> + </span> + </t> + +</templates> diff --git a/addons/mail/static/src/widgets/discuss/discuss.js b/addons/mail/static/src/widgets/discuss/discuss.js new file mode 100644 index 00000000..86f3ba32 --- /dev/null +++ b/addons/mail/static/src/widgets/discuss/discuss.js @@ -0,0 +1,397 @@ +odoo.define('mail/static/src/widgets/discuss/discuss.js', function (require) { +'use strict'; + +const components = { + Discuss: require('mail/static/src/components/discuss/discuss.js'), +}; +const InvitePartnerDialog = require('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js'); + +const AbstractAction = require('web.AbstractAction'); +const { action_registry, qweb } = require('web.core'); + +const { Component } = owl; + +const DiscussWidget = AbstractAction.extend({ + template: 'mail.widgets.Discuss', + hasControlPanel: true, + loadControlPanel: true, + withSearchBar: true, + searchMenuTypes: ['filter', 'favorite'], + /** + * @override {web.AbstractAction} + * @param {web.ActionManager} parent + * @param {Object} action + * @param {Object} [action.context] + * @param {string} [action.context.active_id] + * @param {Object} [action.params] + * @param {string} [action.params.default_active_id] + * @param {Object} [options={}] + */ + init(parent, action, options={}) { + this._super(...arguments); + + // render buttons in control panel + this.$buttons = $(qweb.render('mail.widgets.Discuss.DiscussControlButtons')); + this.$buttons.find('button').css({ display: 'inline-block' }); + this.$buttons.on('click', '.o_invite', ev => this._onClickInvite(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonMarkAllRead', + ev => this._onClickMarkAllAsRead(ev) + ); + this.$buttons.on('click', '.o_mobile_new_channel', ev => this._onClickMobileNewChannel(ev)); + this.$buttons.on('click', '.o_mobile_new_message', ev => this._onClickMobileNewMessage(ev)); + this.$buttons.on('click', '.o_unstar_all', ev => this._onClickUnstarAll(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonSelectAll', ev => this._onClickSelectAll(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonUnselectAll', ev => this._onClickUnselectAll(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-accept', ev => this._onClickModerationAccept(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-discard', ev => this._onClickModerationDiscard(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-reject', ev => this._onClickModerationReject(ev)); + + // control panel attributes + this.action = action; + this.actionManager = parent; + this.searchModelConfig.modelName = 'mail.message'; + this.discuss = undefined; + this.options = options; + + this.component = undefined; + + this._lastPushStateActiveThread = null; + }, + /** + * @override + */ + async willStart() { + await this._super(...arguments); + this.env = Component.env; + await this.env.messagingCreatedPromise; + const initActiveId = this.options.active_id || + (this.action.context && this.action.context.active_id) || + (this.action.params && this.action.params.default_active_id) || + 'mail.box_inbox'; + this.discuss = this.env.messaging.discuss; + this.discuss.update({ initActiveId }); + }, + /** + * @override {web.AbstractAction} + */ + destroy() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.$buttons) { + this.$buttons.off().remove(); + } + this._super(...arguments); + }, + /** + * @override {web.AbstractAction} + */ + on_attach_callback() { + this._super(...arguments); + if (this.component) { + // prevent twice call to on_attach_callback (FIXME) + return; + } + const DiscussComponent = components.Discuss; + this.component = new DiscussComponent(); + this._pushStateActionManagerEventListener = ev => { + ev.stopPropagation(); + if (this._lastPushStateActiveThread === this.discuss.thread) { + return; + } + this._pushStateActionManager(); + this._lastPushStateActiveThread = this.discuss.thread; + }; + this._showRainbowManEventListener = ev => { + ev.stopPropagation(); + this._showRainbowMan(); + }; + this._updateControlPanelEventListener = ev => { + ev.stopPropagation(); + this._updateControlPanel(); + }; + + this.el.addEventListener( + 'o-push-state-action-manager', + this._pushStateActionManagerEventListener + ); + this.el.addEventListener( + 'o-show-rainbow-man', + this._showRainbowManEventListener + ); + this.el.addEventListener( + 'o-update-control-panel', + this._updateControlPanelEventListener + ); + return this.component.mount(this.el); + }, + /** + * @override {web.AbstractAction} + */ + on_detach_callback() { + this._super(...arguments); + if (this.component) { + this.component.destroy(); + } + this.component = undefined; + this.el.removeEventListener( + 'o-push-state-action-manager', + this._pushStateActionManagerEventListener + ); + this.el.removeEventListener( + 'o-show-rainbow-man', + this._showRainbowManEventListener + ); + this.el.removeEventListener( + 'o-update-control-panel', + this._updateControlPanelEventListener + ); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _pushStateActionManager() { + this.actionManager.do_push_state({ + action: this.action.id, + active_id: this.discuss.activeId, + }); + }, + /** + * @private + * @returns {boolean} + */ + _shouldHaveInviteButton() { + return ( + this.discuss.thread && + this.discuss.thread.channel_type === 'channel' + ); + }, + /** + * @private + */ + _showRainbowMan() { + this.trigger_up('show_effect', { + message: this.env._t("Congratulations, your inbox is empty!"), + type: 'rainbow_man', + }); + }, + /** + * @private + */ + _updateControlPanel() { + // Invite + if (this._shouldHaveInviteButton()) { + this.$buttons.find('.o_invite').removeClass('o_hidden'); + } else { + this.$buttons.find('.o_invite').addClass('o_hidden'); + } + // Mark All Read + if ( + this.discuss.threadView && + this.discuss.thread && + this.discuss.thread === this.env.messaging.inbox + ) { + this.$buttons + .find('.o_widget_Discuss_controlPanelButtonMarkAllRead') + .removeClass('o_hidden') + .prop('disabled', this.discuss.threadView.messages.length === 0); + } else { + this.$buttons + .find('.o_widget_Discuss_controlPanelButtonMarkAllRead') + .addClass('o_hidden'); + } + // Unstar All + if ( + this.discuss.threadView && + this.discuss.thread && + this.discuss.thread === this.env.messaging.starred + ) { + this.$buttons + .find('.o_unstar_all') + .removeClass('o_hidden') + .prop('disabled', this.discuss.threadView.messages.length === 0); + } else { + this.$buttons + .find('.o_unstar_all') + .addClass('o_hidden'); + } + // Mobile: Add channel + if ( + this.env.messaging.device.isMobile && + this.discuss.activeMobileNavbarTabId === 'channel' + ) { + this.$buttons + .find('.o_mobile_new_channel') + .removeClass('o_hidden'); + } else { + this.$buttons + .find('.o_mobile_new_channel') + .addClass('o_hidden'); + } + // Mobile: Add message + if ( + this.env.messaging.device.isMobile && + this.discuss.activeMobileNavbarTabId === 'chat' + ) { + this.$buttons + .find('.o_mobile_new_message') + .removeClass('o_hidden'); + } else { + this.$buttons + .find('.o_mobile_new_message') + .addClass('o_hidden'); + } + // Select All & Unselect All + const $selectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonSelectAll'); + const $unselectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonUnselectAll'); + + if ( + this.discuss.threadView && + ( + this.discuss.threadView.checkedMessages.length > 0 || + this.discuss.threadView.uncheckedMessages.length > 0 + ) + ) { + $selectAll.removeClass('o_hidden'); + $selectAll.toggleClass('disabled', this.discuss.threadView.uncheckedMessages.length === 0); + $unselectAll.removeClass('o_hidden'); + $unselectAll.toggleClass('disabled', this.discuss.threadView.checkedMessages.length === 0); + } else { + $selectAll.addClass('o_hidden'); + $selectAll.addClass('disabled'); + $unselectAll.addClass('o_hidden'); + $unselectAll.addClass('disabled'); + } + + // Moderation Actions + const $moderationButtons = this.$buttons.find('.o_widget_Discuss_controlPanelButtonModeration'); + if ( + this.discuss.threadView && + this.discuss.threadView.checkedMessages.length > 0 && + this.discuss.threadView.checkedMessages.filter( + message => !message.isModeratedByCurrentPartner + ).length === 0 + ) { + $moderationButtons.removeClass('o_hidden'); + } else { + $moderationButtons.addClass('o_hidden'); + } + + let title; + if (this.env.messaging.device.isMobile || !this.discuss.thread) { + title = this.env._t("Discuss"); + } else { + const prefix = + this.discuss.thread.channel_type === 'channel' && + this.discuss.thread.public !== 'private' + ? '#' + : ''; + title = `${prefix}${this.discuss.thread.displayName}`; + } + + this.updateControlPanel({ + cp_content: { + $buttons: this.$buttons, + }, + title, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickInvite() { + new InvitePartnerDialog(this, { + activeThreadLocalId: this.discuss.thread.localId, + messagingEnv: this.env, + }).open(); + }, + /** + * @private + */ + _onClickMarkAllAsRead() { + this.env.models['mail.message'].markAllAsRead(this.domain); + }, + /** + * @private + */ + _onClickMobileNewChannel() { + this.discuss.update({ isAddingChannel: true }); + }, + /** + * @private + */ + _onClickMobileNewMessage() { + this.discuss.update({ isAddingChat: true }); + }, + /** + * @private + */ + _onClickModerationAccept() { + this.env.models['mail.message'].moderate( + this.discuss.threadView.checkedMessages, + 'accept' + ); + }, + /** + * @private + */ + _onClickModerationDiscard() { + this.discuss.update({ hasModerationDiscardDialog: true }); + }, + /** + * @private + */ + _onClickModerationReject() { + this.discuss.update({ hasModerationRejectDialog: true }); + }, + /** + * @private + */ + _onClickSelectAll() { + this.env.models['mail.message'].checkAll( + this.discuss.thread, + this.discuss.stringifiedDomain + ); + }, + /** + * @private + */ + _onClickUnselectAll() { + this.env.models['mail.message'].uncheckAll( + this.discuss.thread, + this.discuss.stringifiedDomain + ); + }, + /** + * @private + */ + _onClickUnstarAll() { + this.env.models['mail.message'].unstarAll(); + }, + /** + * @private + * @param {Object} searchQuery + */ + _onSearch: function (searchQuery) { + this.discuss.update({ + stringifiedDomain: JSON.stringify(searchQuery.domain), + }); + }, +}); + +action_registry.add('mail.widgets.discuss', DiscussWidget); + +return DiscussWidget; + +}); diff --git a/addons/mail/static/src/widgets/discuss/discuss.scss b/addons/mail/static/src/widgets/discuss/discuss.scss new file mode 100644 index 00000000..2bed9b7c --- /dev/null +++ b/addons/mail/static/src/widgets/discuss/discuss.scss @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_widget_Discuss { + display: flex; + flex: 0 0 100%; + flex-flow: column; + height: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_widget_Discuss { + + .o_control_panel { + border-bottom: 0; // cancel default border, so that we only apply it on top of discuss content + } + + .o_Discuss_content { + border-top: 1px solid darken($o-control-panel-background-color, 20%); + } + + .o_Discuss.o-mobile { + + &:not(.o-adding-item) { + border-top: 1px solid darken($o-control-panel-background-color, 20%); + } + + &.o-adding-item .o_Discuss_mobileAddItemHeader { + border-bottom: 1px solid darken($o-control-panel-background-color, 20%); + } + } +} diff --git a/addons/mail/static/src/widgets/discuss/discuss.xml b/addons/mail/static/src/widgets/discuss/discuss.xml new file mode 100644 index 00000000..f8e6de37 --- /dev/null +++ b/addons/mail/static/src/widgets/discuss/discuss.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- + @param {mail/static/src/widgets/discuss/discuss.js} widget + --> + <t t-name="mail.widgets.Discuss"> + <div class="o_widget_Discuss"/> + </t> + + <!-- @param {boolean} isMobile --> + <t t-name="mail.widgets.Discuss.DiscussControlButtons"> + <div> + <button class="o_widget_Discuss_controlPanelButtonInvite o_invite o_hidden btn btn-primary" type="button" title="Invite people">Invite</button> + <button class="o_widget_Discuss_controlPanelButtonMarkAllRead o_hidden btn btn-secondary" type="button" title="Mark all as read">Mark all read</button> + <button class="o_widget_Discuss_controlPanelButtonUnstarAll o_unstar_all o_hidden btn btn-secondary" type="button" title="Unstar all messages">Unstar all</button> + <button class="o_widget_Discuss_controlPanelButtonMobileNewMessage o_mobile_new_message o_hidden btn btn-secondary" type="button" title="New Message">New Message</button> + <button class="o_widget_Discuss_controlPanelButtonMobileNewChannel o_mobile_new_channel o_hidden btn btn-secondary" title="New Channel" type="button">New Channel</button> + <button class="o_widget_Discuss_controlPanelButtonSelectAll btn btn-secondary o_hidden" title="Select all messages">Select All</button> + <button class="o_widget_Discuss_controlPanelButtonUnselectAll btn btn-secondary o_hidden" title="Unselect all messages">Unselect All</button> + <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-accept" title="Accept selected messages">Accept</button> + <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-reject" title="Reject selected messages">Reject</button> + <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-discard" title="Discard selected messages">Discard</button> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js new file mode 100644 index 00000000..6d9a051b --- /dev/null +++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js @@ -0,0 +1,124 @@ +odoo.define('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js', function (require) { +'use strict'; + +const core = require('web.core'); +const Dialog = require('web.Dialog'); + +const _lt = core._lt; +const QWeb = core.qweb; + +/** + * Widget : Invite People to Channel Dialog + * + * Popup containing a 'many2many_tags' custom input to select multiple partners. + * Searches user according to the input, and triggers event when selection is + * validated. + */ +const PartnerInviteDialog = Dialog.extend({ + dialog_title: _lt("Invite people"), + template: 'mail.widgets.DiscussInvitePartnerDialog', + /** + * @override {web.Dialog} + * @param {mail/static/src/widgets/discuss/discuss.js} parent + * @param {Object} param1 + * @param {string} param1.activeThreadLocalId + * @param {Object} param1.messagingEnv + * @param {Object} param1.messagingEnv.store + */ + init(parent, { activeThreadLocalId, messagingEnv }) { + const env = messagingEnv; + const channel = env.models['mail.thread'].get(activeThreadLocalId); + this.channelId = channel.id; + this.env = env; + this._super(parent, { + title: _.str.sprintf(this.env._t("Invite people to #%s"), owl.utils.escape(channel.displayName)), + size: 'medium', + buttons: [{ + text: this.env._t("Invite"), + close: true, + classes: 'btn-primary', + click: ev => this._invite(ev), + }], + }); + }, + /** + * @override {web.Dialog} + * @returns {Promise} + */ + start() { + this.$input = this.$('.o_input'); + this.$input.select2({ + width: '100%', + allowClear: true, + multiple: true, + formatResult: item => { + let status; + // TODO FIXME fix this, why do we even have an old widget here + if (item.id === 'odoobot') { + status = 'bot'; + } else { + const partner = this.env.models['mail.partner'].findFromIdentifyingData({ + id: item.id, + }); + status = partner.im_status; + } + const $status = QWeb.render('mail.widgets.UserStatus', { status }); + return $('<span>').text(item.text).prepend($status); + }, + query: query => { + this.env.models['mail.partner'].imSearch({ + callback: partners => { + let results = partners.map(partner => { + return { + id: partner.id, + label: partner.nameOrDisplayName, + text: partner.nameOrDisplayName, + value: partner.nameOrDisplayName, + }; + }); + results = _.sortBy(results, 'label'); + query.callback({ results }); + }, + keyword: query.term, + limit: 20, + }); + } + }); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + async _invite() { + const data = this.$input.select2('data'); + if (data.length === 0) { + return; + } + await this._rpc({ + model: 'mail.channel', + method: 'channel_invite', + args: [this.channelId], + kwargs: { + partner_ids: _.pluck(data, 'id') + }, + }); + const names = _.escape(_.pluck(data, 'text').join(', ')); + const notification = _.str.sprintf( + this.env._t("You added <b>%s</b> to the conversation."), + names + ); + this.env.services['notification'].notify({ + message: notification, + type: 'warning', + }); + }, +}); + +return PartnerInviteDialog; + +}); diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml new file mode 100644 index 00000000..82553476 --- /dev/null +++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- + @param {mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js} widget + --> + <t t-name="mail.widgets.DiscussInvitePartnerDialog"> + <div> + <input class="o_dialog o_input o_invite_partner" type="text"/> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.js b/addons/mail/static/src/widgets/form_renderer/form_renderer.js new file mode 100644 index 00000000..cf147656 --- /dev/null +++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.js @@ -0,0 +1,188 @@ +odoo.define('mail/static/src/widgets/form_renderer/form_renderer.js', function (require) { +"use strict"; + +const components = { + ChatterContainer: require('mail/static/src/components/chatter_container/chatter_container.js'), +}; + +const FormRenderer = require('web.FormRenderer'); +const { ComponentWrapper } = require('web.OwlCompatibility'); + +class ChatterContainerWrapperComponent extends ComponentWrapper {} + +/** + * Include the FormRenderer to instantiate the chatter area containing (a + * subset of) the mail widgets (mail_thread, mail_followers and mail_activity). + */ +FormRenderer.include({ + /** + * @override + */ + init(parent, state, params) { + this._super(...arguments); + this.chatterFields = params.chatterFields; + this.mailFields = params.mailFields; + this._chatterContainerComponent = undefined; + /** + * The target of chatter, if chatter has to be appended to the DOM. + * This is set when arch contains `div.oe_chatter`. + */ + this._chatterContainerTarget = undefined; + // Do not load chatter in form view dialogs + this._isFromFormViewDialog = params.isFromFormViewDialog; + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this._chatterContainerComponent = undefined; + this.off('o_attachments_changed', this); + this.off('o_chatter_rendered', this); + this.off('o_message_posted', this); + owl.Component.env.bus.off('mail.thread:promptAddFollower-closed', this); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns whether the form renderer has a chatter to display or not. + * This is based on arch, which should have `div.oe_chatter`. + * + * @private + * @returns {boolean} + */ + _hasChatter() { + return !!this._chatterContainerTarget; + }, + /** + * @private + */ + _makeChatterContainerComponent() { + const props = this._makeChatterContainerProps(); + this._chatterContainerComponent = new ChatterContainerWrapperComponent( + this, + components.ChatterContainer, + props + ); + // Not in custom_events because other modules may remove this listener + // while attempting to extend them. + this.on('o_chatter_rendered', this, ev => this._onChatterRendered(ev)); + if (this.chatterFields.hasRecordReloadOnMessagePosted) { + this.on('o_message_posted', this, ev => { + this.trigger_up('reload', { keepChanges: true }); + }); + } + if (this.chatterFields.hasRecordReloadOnAttachmentsChanged) { + this.on('o_attachments_changed', this, ev => this.trigger_up('reload', { keepChanges: true })); + } + if (this.chatterFields.hasRecordReloadOnFollowersUpdate) { + owl.Component.env.bus.on('mail.thread:promptAddFollower-closed', this, ev => this.trigger_up('reload', { keepChanges: true })); + } + }, + /** + * @private + * @returns {Object} + */ + _makeChatterContainerProps() { + return { + hasActivities: this.chatterFields.hasActivityIds, + hasFollowers: this.chatterFields.hasMessageFollowerIds, + hasMessageList: this.chatterFields.hasMessageIds, + isAttachmentBoxVisibleInitially: this.chatterFields.isAttachmentBoxVisibleInitially, + threadId: this.state.res_id, + threadModel: this.state.model, + }; + }, + /** + * Create the DOM element that will contain the chatter. This is made in + * a separate method so it can be overridden (like in mail_enterprise for + * example). + * + * @private + * @returns {jQuery.Element} + */ + _makeChatterContainerTarget() { + const $el = $('<div class="o_FormRenderer_chatterContainer"/>'); + this._chatterContainerTarget = $el[0]; + return $el; + }, + /** + * Mount the chatter + * + * Force re-mounting chatter component in DOM. This is necessary + * because each time `_renderView` is called, it puts old content + * in a fragment. + * + * @private + */ + async _mountChatterContainerComponent() { + try { + await this._chatterContainerComponent.mount(this._chatterContainerTarget); + } catch (error) { + if (error.message !== "Mounting operation cancelled") { + throw error; + } + } + }, + /** + * @override + */ + _renderNode(node) { + if (node.tag === 'div' && node.attrs.class === 'oe_chatter') { + if (this._isFromFormViewDialog) { + return $('<div/>'); + } + return this._makeChatterContainerTarget(); + } + return this._super(...arguments); + }, + /** + * Overrides the function to render the chatter once the form view is + * rendered. + * + * @override + */ + async __renderView() { + await this._super(...arguments); + if (this._hasChatter()) { + if (!this._chatterContainerComponent) { + this._makeChatterContainerComponent(); + } else { + await this._updateChatterContainerComponent(); + } + await this._mountChatterContainerComponent(); + } + }, + /** + * @private + */ + async _updateChatterContainerComponent() { + const props = this._makeChatterContainerProps(); + try { + await this._chatterContainerComponent.update(props); + } catch (error) { + if (error.message !== "Mounting operation cancelled") { + throw error; + } + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @abstract + * @private + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {mail.attachment[]} ev.data.attachments + * @param {mail.thread} ev.data.thread + */ + _onChatterRendered(ev) {}, +}); + +}); diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.scss b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss new file mode 100644 index 00000000..3092055b --- /dev/null +++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FormRenderer_chatterContainer { + display: flex; + flex: 1 1 auto; + margin: 0 auto; + max-width: $o-form-view-sheet-max-width; + padding: map-get($spacers, 3) map-get($spacers, 3) map-get($spacers, 5); + width: 100%; +} + +// FIX to hide chatter in dialogs when they are opened from an action returned by python code +.modal .modal-dialog .o_form_view .o_FormRenderer_chatterContainer { + display: none; +} diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js new file mode 100644 index 00000000..90cdb169 --- /dev/null +++ b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js @@ -0,0 +1,982 @@ +odoo.define('mail/static/src/widgets/form_renderer/form_renderer_tests.js', function (require) { +"use strict"; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { + afterEach, + afterNextRender, + beforeEach, + nextAnimationFrame, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const config = require('web.config'); +const FormView = require('web.FormView'); +const { + dom: { triggerEvent }, +} = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('widgets', {}, function () { +QUnit.module('form_renderer', {}, function () { +QUnit.module('form_renderer_tests.js', { + beforeEach() { + beforeEach(this); + + // FIXME archs could be removed once task-2248306 is done + // The mockServer will try to get the list view + // of every relational fields present in the main view. + // In the case of mail fields, we don't really need them, + // but they still need to be defined. + this.createView = async (viewParams, ...args) => { + await afterNextRender(async () => { + const viewArgs = Object.assign( + { + archs: { + 'mail.activity,false,list': '<tree/>', + 'mail.followers,false,list': '<tree/>', + 'mail.message,false,list': '<tree/>', + }, + }, + viewParams, + ); + const { afterEvent, env, widget } = await start(viewArgs, ...args); + this.afterEvent = afterEvent; + this.env = env; + this.widget = widget; + }); + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('[technical] spinner when messaging is not created', async function (assert) { + /** + * Creation of messaging in env is async due to generation of models being + * async. Generation of models is async because it requires parsing of all + * JS modules that contain pieces of model definitions. + * + * Time of having no messaging is very short, almost imperceptible by user + * on UI, but the display should not crash during this critical time period. + */ + assert.expect(3); + + this.data['res.partner'].records.push({ + display_name: "second partner", + id: 12, + }); + await this.createView({ + data: this.data, + hasView: true, + messagingBeforeCreationDeferred: makeDeferred(), // block messaging creation + waitUntilMessagingCondition: 'none', + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"></div> + </form> + `, + res_id: 12, + }); + assert.containsOnce( + document.body, + '.o_ChatterContainer', + "should display chatter container even when messaging is not created yet" + ); + assert.containsOnce( + document.body, + '.o_ChatterContainer_noChatter', + "chatter container should not display any chatter when messaging not created" + ); + assert.strictEqual( + document.querySelector('.o_ChatterContainer').textContent, + "Please wait...", + "chatter container should display spinner when messaging not yet created" + ); +}); + +QUnit.test('[technical] keep spinner on transition from messaging non-created to messaging created (and non-initialized)', async function (assert) { + /** + * Creation of messaging in env is async due to generation of models being + * async. Generation of models is async because it requires parsing of all + * JS modules that contain pieces of model definitions. + * + * Time of having no messaging is very short, almost imperceptible by user + * on UI, but the display should not crash during this critical time period. + */ + assert.expect(4); + + const messagingBeforeCreationDeferred = makeDeferred(); + this.data['res.partner'].records.push({ + display_name: "second partner", + id: 12, + }); + await this.createView({ + data: this.data, + hasView: true, + messagingBeforeCreationDeferred, + async mockRPC(route, args) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await new Promise(() => {}); // simulate messaging never initialized + } + return _super(); + }, + waitUntilMessagingCondition: 'none', + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"></div> + </form> + `, + res_id: 12, + }); + assert.strictEqual( + document.querySelector('.o_ChatterContainer').textContent, + "Please wait...", + "chatter container should display spinner when messaging not yet created" + ); + assert.containsOnce( + document.body, + '.o_ChatterContainer_noChatter', + "chatter container should not display any chatter when messaging not created" + ); + + // simulate messaging become created + messagingBeforeCreationDeferred.resolve(); + await nextAnimationFrame(); + assert.strictEqual( + document.querySelector('.o_ChatterContainer').textContent, + "Please wait...", + "chatter container should still display spinner when messaging is created but not initialized" + ); + assert.containsOnce( + document.body, + '.o_ChatterContainer_noChatter', + "chatter container should still not display any chatter when messaging not initialized" + ); +}); + +QUnit.test('spinner when messaging is created but not initialized', async function (assert) { + assert.expect(3); + + this.data['res.partner'].records.push({ + display_name: "second partner", + id: 12, + }); + await this.createView({ + data: this.data, + hasView: true, + async mockRPC(route, args) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await new Promise(() => {}); // simulate messaging never initialized + } + return _super(); + }, + waitUntilMessagingCondition: 'created', + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"></div> + </form> + `, + res_id: 12, + }); + assert.containsOnce( + document.body, + '.o_ChatterContainer', + "should display chatter container even when messaging is not fully initialized" + ); + assert.containsOnce( + document.body, + '.o_ChatterContainer_noChatter', + "chatter container should not display any chatter when messaging not initialized" + ); + assert.strictEqual( + document.querySelector('.o_ChatterContainer').textContent, + "Please wait...", + "chatter container should display spinner when messaging not yet initialized" + ); +}); + +QUnit.test('transition non-initialized messaging to initialized messaging: display spinner then chatter', async function (assert) { + assert.expect(3); + + const messagingBeforeInitializationDeferred = makeDeferred(); + this.data['res.partner'].records.push({ + display_name: "second partner", + id: 12, + }); + await this.createView({ + data: this.data, + hasView: true, + async mockRPC(route, args) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await messagingBeforeInitializationDeferred; + } + return _super(); + }, + waitUntilMessagingCondition: 'created', + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"></div> + </form> + `, + res_id: 12, + }); + assert.strictEqual( + document.querySelector('.o_ChatterContainer').textContent, + "Please wait...", + "chatter container should display spinner when messaging not yet initialized" + ); + assert.containsOnce( + document.body, + '.o_ChatterContainer_noChatter', + "chatter container should not display any chatter when messaging not initialized" + ); + + // Simulate messaging becomes initialized + await afterNextRender(() => messagingBeforeInitializationDeferred.resolve()); + assert.containsNone( + document.body, + '.o_ChatterContainer_noChatter', + "chatter container should now display chatter when messaging becomes initialized" + ); +}); + +QUnit.test('basic chatter rendering', async function (assert) { + assert.expect(1); + + this.data['res.partner'].records.push({ display_name: "second partner", id: 12, }); + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"></div> + </form> + `, + res_id: 12, + }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "there should be a chatter" + ); +}); + +QUnit.test('basic chatter rendering without followers', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push({ display_name: "second partner", id: 12 }); + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="activity_ids"/> + <field name="message_ids"/> + </div> + </form> + `, + res_id: 12, + }); + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar', + "there should be a chatter topbar" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonAttachments', + "there should be an attachment button" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonScheduleActivity', + "there should be a schedule activity button" + ); + assert.containsNone( + document.body, + '.o_FollowerListMenu', + "there should be no followers menu" + ); + assert.containsOnce( + document.body, + '.o_Chatter_thread', + "there should be a thread" + ); +}); + +QUnit.test('basic chatter rendering without activities', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push({ display_name: "second partner", id: 12 }); + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_follower_ids"/> + <field name="message_ids"/> + </div> + </form> + `, + res_id: 12, + }); + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar', + "there should be a chatter topbar" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonAttachments', + "there should be an attachment button" + ); + assert.containsNone( + document.body, + '.o_ChatterTopbar_buttonScheduleActivity', + "there should be a schedule activity button" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "there should be a followers menu" + ); + assert.containsOnce( + document.body, + '.o_Chatter_thread', + "there should be a thread" + ); +}); + +QUnit.test('basic chatter rendering without messages', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push({ display_name: "second partner", id: 12 }); + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_follower_ids"/> + <field name="activity_ids"/> + </div> + </form> + `, + res_id: 12, + }); + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar', + "there should be a chatter topbar" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonAttachments', + "there should be an attachment button" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonScheduleActivity', + "there should be a schedule activity button" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "there should be a followers menu" + ); + assert.containsNone( + document.body, + '.o_Chatter_thread', + "there should be a thread" + ); +}); + +QUnit.test('chatter updating', async function (assert) { + assert.expect(1); + + this.data['mail.message'].records.push({ body: "not empty", model: 'res.partner', res_id: 12 }); + this.data['res.partner'].records.push( + { display_name: "first partner", id: 11 }, + { display_name: "second partner", id: 12 } + ); + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + res_id: 11, + viewOptions: { + ids: [11, 12], + index: 0, + }, + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + waitUntilEvent: { + eventName: 'o-thread-view-hint-processed', + message: "should wait until partner 11 thread loaded messages initially", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'res.partner' && + threadViewer.thread.id === 11 + ); + }, + } + }); + + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => document.querySelector('.o_pager_next').click(), + message: "should wait until partner 12 thread loaded messages after clicking on next", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'messages-loaded' && + threadViewer.thread.model === 'res.partner' && + threadViewer.thread.id === 12 + ); + }, + }); + assert.containsOnce( + document.body, + '.o_Message', + "there should be a message in partner 12 thread" + ); +}); + +QUnit.test('chatter should become enabled when creation done', async function (assert) { + assert.expect(10); + + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + viewOptions: { + mode: 'edit', + }, + }); + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonSendMessage', + "there should be a send message button" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonLogNote', + "there should be a log note button" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonLogNote', + "there should be an attachments button" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled, + "send message button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled, + "log note button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled, + "attachments button should be disabled" + ); + + document.querySelectorAll('.o_field_char')[0].focus(); + document.execCommand('insertText', false, "hello"); + await afterNextRender(() => { + document.querySelector('.o_form_button_save').click(); + }); + assert.notOk( + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled, + "send message button should now be enabled" + ); + assert.notOk( + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled, + "log note button should now be enabled" + ); + assert.notOk( + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled, + "attachments button should now be enabled" + ); +}); + +QUnit.test('read more/less links are not duplicated when switching from read to edit mode', async function (assert) { + assert.expect(5); + + this.data['mail.message'].records.push({ + author_id: 100, + // "data-o-mail-quote" added by server is intended to be compacted in read more/less blocks + body: ` + <div> + Dear Joel Willis,<br> + Thank you for your enquiry.<br> + If you have any questions, please let us know. + <br><br> + Thank you,<br> + <span data-o-mail-quote="1">-- <br data-o-mail-quote="1"> + System + </span> + </div> + `, + id: 1000, + model: 'res.partner', + res_id: 2, + }); + this.data['res.partner'].records.push({ + display_name: "Someone", + id: 100, + }); + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + res_id: 2, + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + waitUntilEvent: { + eventName: 'o-component-message-read-more-less-inserted', + message: "should wait until read more/less is inserted initially", + predicate: ({ message }) => message.id === 1000, + }, + }); + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_Message', + "there should be a message" + ); + assert.containsOnce( + document.body, + '.o_Message_readMoreLess', + "there should be only one read more" + ); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-component-message-read-more-less-inserted', + func: () => document.querySelector('.o_form_button_edit').click(), + message: "should wait until read more/less is inserted after clicking on edit", + predicate: ({ message }) => message.id === 1000, + })); + assert.containsOnce( + document.body, + '.o_Message_readMoreLess', + "there should still be only one read more after switching to edit mode" + ); + + await afterNextRender(() => this.afterEvent({ + eventName: 'o-component-message-read-more-less-inserted', + func: () => document.querySelector('.o_form_button_cancel').click(), + message: "should wait until read more/less is inserted after canceling edit", + predicate: ({ message }) => message.id === 1000, + })); + assert.containsOnce( + document.body, + '.o_Message_readMoreLess', + "there should still be only one read more after switching back to read mode" + ); +}); + +QUnit.test('read more links becomes read less after being clicked', async function (assert) { + assert.expect(6); + + this.data['mail.message'].records = [{ + author_id: 100, + // "data-o-mail-quote" added by server is intended to be compacted in read more/less blocks + body: ` + <div> + Dear Joel Willis,<br> + Thank you for your enquiry.<br> + If you have any questions, please let us know. + <br><br> + Thank you,<br> + <span data-o-mail-quote="1">-- <br data-o-mail-quote="1"> + System + </span> + </div> + `, + id: 1000, + model: 'res.partner', + res_id: 2, + }]; + this.data['res.partner'].records.push({ + display_name: "Someone", + id: 100, + }); + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + res_id: 2, + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + waitUntilEvent: { + eventName: 'o-component-message-read-more-less-inserted', + message: "should wait until read more/less is inserted initially", + predicate: ({ message }) => message.id === 1000, + }, + }); + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_Message', + "there should be a message" + ); + assert.containsOnce( + document.body, + '.o_Message_readMoreLess', + "there should be a read more" + ); + assert.strictEqual( + document.querySelector('.o_Message_readMoreLess').textContent, + 'read more', + "read more/less link should contain 'read more' as text" + ); + + await afterNextRender(() => this.afterEvent({ + eventName: 'o-component-message-read-more-less-inserted', + func: () => document.querySelector('.o_form_button_edit').click(), + message: "should wait until read more/less is inserted after clicking on edit", + predicate: ({ message }) => message.id === 1000, + })); + assert.strictEqual( + document.querySelector('.o_Message_readMoreLess').textContent, + 'read more', + "read more/less link should contain 'read more' as text" + ); + + document.querySelector('.o_Message_readMoreLess').click(); + assert.strictEqual( + document.querySelector('.o_Message_readMoreLess').textContent, + 'read less', + "read more/less link should contain 'read less' as text after it has been clicked" + ); +}); + +QUnit.test('Form view not scrolled when switching record', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records.push( + { + id: 11, + display_name: "Partner 1", + description: [...Array(60).keys()].join('\n'), + }, + { + id: 12, + display_name: "Partner 2", + } + ); + + const messages = [...Array(60).keys()].map(id => { + return { + model: 'res.partner', + res_id: id % 2 ? 11 : 12, + }; + }); + this.data['mail.message'].records = messages; + + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + <field name="description"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + viewOptions: { + currentId: 11, + ids: [11, 12], + }, + config: { + device: { size_class: config.device.SIZES.LG }, + }, + env: { + device: { size_class: config.device.SIZES.LG }, + }, + }); + + const controllerContentEl = document.querySelector('.o_content'); + + assert.strictEqual( + document.querySelector('.breadcrumb-item.active').textContent, + 'Partner 1', + "Form view should display partner 'Partner 1'" + ); + assert.strictEqual(controllerContentEl.scrollTop, 0, + "The top of the form view is visible" + ); + + await afterNextRender(async () => { + controllerContentEl.scrollTop = controllerContentEl.scrollHeight - controllerContentEl.clientHeight; + await triggerEvent( + document.querySelector('.o_ThreadView_messageList'), + 'scroll' + ); + }); + assert.strictEqual( + controllerContentEl.scrollTop, + controllerContentEl.scrollHeight - controllerContentEl.clientHeight, + "The controller container should be scrolled to its bottom" + ); + + await afterNextRender(() => + document.querySelector('.o_pager_next').click() + ); + assert.strictEqual( + document.querySelector('.breadcrumb-item.active').textContent, + 'Partner 2', + "The form view should display partner 'Partner 2'" + ); + assert.strictEqual(controllerContentEl.scrollTop, 0, + "The top of the form view should be visible when switching record from pager" + ); + + await afterNextRender(() => + document.querySelector('.o_pager_previous').click() + ); + assert.strictEqual(controllerContentEl.scrollTop, 0, + "Form view's scroll position should have been reset when switching back to first record" + ); +}); + +QUnit.test('Attachments that have been unlinked from server should be visually unlinked from record', async function (assert) { + // Attachments that have been fetched from a record at certain time and then + // removed from the server should be reflected on the UI when the current + // partner accesses this record again. + assert.expect(2); + + this.data['res.partner'].records.push( + { display_name: "Partner1", id: 11 }, + { display_name: "Partner2", id: 12 } + ); + this.data['ir.attachment'].records.push( + { + id: 11, + mimetype: 'text.txt', + res_id: 11, + res_model: 'res.partner', + }, + { + id: 12, + mimetype: 'text.txt', + res_id: 11, + res_model: 'res.partner', + } + ); + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + res_id: 11, + viewOptions: { + ids: [11, 12], + index: 0, + }, + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + }); + assert.strictEqual( + document.querySelector('.o_ChatterTopbar_buttonCount').textContent, + '2', + "Partner1 should have 2 attachments initially" + ); + + // The attachment links are updated on (re)load, + // so using pager is a way to reload the record "Partner1". + await afterNextRender(() => + document.querySelector('.o_pager_next').click() + ); + // Simulate unlinking attachment 12 from Partner 1. + this.data['ir.attachment'].records.find(a => a.id === 11).res_id = 0; + await afterNextRender(() => + document.querySelector('.o_pager_previous').click() + ); + assert.strictEqual( + document.querySelector('.o_ChatterTopbar_buttonCount').textContent, + '1', + "Partner1 should now have 1 attachment after it has been unlinked from server" + ); +}); + +QUnit.test('chatter just contains "creating a new record" message during the creation of a new record after having displayed a chatter for an existing record', async function (assert) { + assert.expect(2); + + this.data['res.partner'].records.push({ id: 12 }); + await this.createView({ + data: this.data, + hasView: true, + View: FormView, + model: 'res.partner', + res_id: 12, + arch: ` + <form> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + }); + + await afterNextRender(() => { + document.querySelector('.o_form_button_create').click(); + }); + assert.containsOnce( + document.body, + '.o_Message', + "Should have a single message when creating a new record" + ); + assert.strictEqual( + document.querySelector('.o_Message_content').textContent, + 'Creating a new record...', + "the message content should be in accord to the creation of this record" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js new file mode 100644 index 00000000..edfef630 --- /dev/null +++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js @@ -0,0 +1,56 @@ +odoo.define('mail/static/src/widgets/messaging_menu/messaging_menu.js', function (require) { +'use strict'; + +const components = { + MessagingMenu: require('mail/static/src/components/messaging_menu/messaging_menu.js'), +}; + +const SystrayMenu = require('web.SystrayMenu'); +const Widget = require('web.Widget'); + +/** + * Odoo Widget, necessary to instantiate component. + */ +const MessagingMenu = Widget.extend({ + template: 'mail.widgets.MessagingMenu', + /** + * @override + */ + init() { + this._super(...arguments); + this.component = undefined; + }, + /** + * @override + */ + destroy() { + if (this.component) { + this.component.destroy(); + } + this._super(...arguments); + }, + async on_attach_callback() { + const MessagingMenuComponent = components.MessagingMenu; + this.component = new MessagingMenuComponent(null); + await this.component.mount(this.el); + // unwrap + this.el.parentNode.insertBefore(this.component.el, this.el); + this.el.parentNode.removeChild(this.el); + }, +}); + +// Systray menu items display order matches order in the list +// lower index comes first, and display is from right to left. +// For messagin menu, it should come before activity menu, if any +// otherwise, it is the next systray item. +const activityMenuIndex = SystrayMenu.Items.findIndex(SystrayMenuItem => + SystrayMenuItem.prototype.name === 'activity_menu'); +if (activityMenuIndex > 0) { + SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu); +} else { + SystrayMenu.Items.push(MessagingMenu); +} + +return MessagingMenu; + +}); diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml new file mode 100644 index 00000000..308c1f31 --- /dev/null +++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.widgets.MessagingMenu"> + <li class="o_widget_SystrayMessagingItem"/> + </t> + +</templates> diff --git a/addons/mail/static/src/widgets/notification_alert/notification_alert.js b/addons/mail/static/src/widgets/notification_alert/notification_alert.js new file mode 100644 index 00000000..27055cd7 --- /dev/null +++ b/addons/mail/static/src/widgets/notification_alert/notification_alert.js @@ -0,0 +1,45 @@ +odoo.define('mail/static/src/widgets/notification_alert/notification_alert.js', function (require) { +"use strict"; + +const components = { + NotificationAlert: require('mail/static/src/components/notification_alert/notification_alert.js'), +}; + +const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility'); + +const Widget = require('web.Widget'); +const widgetRegistry = require('web.widget_registry'); + +class NotificationAlertWrapper extends ComponentWrapper {} + +// ----------------------------------------------------------------------------- +// Display Notification alert on user preferences form view +// ----------------------------------------------------------------------------- +const NotificationAlert = Widget.extend(WidgetAdapterMixin, { + /** + * @override + */ + init() { + this._super(...arguments); + this.component = undefined; + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + + this.component = new NotificationAlertWrapper( + this, + components.NotificationAlert, + {} + ); + await this.component.mount(this.el); + }, +}); + +widgetRegistry.add('notification_alert', NotificationAlert); + +return NotificationAlert; + +}); diff --git a/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js b/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js new file mode 100644 index 00000000..20297c85 --- /dev/null +++ b/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js @@ -0,0 +1,103 @@ +odoo.define('mail/static/src/widgets/notification_alert/notification_alert_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +const FormView = require('web.FormView'); + +QUnit.module('mail', {}, function () { +QUnit.module('widgets', {}, function () { +QUnit.module('notification_alert', {}, function () { +QUnit.module('notification_alert_tests.js', { + beforeEach() { + beforeEach(this); + + this.start = async params => { + let { widget } = await start(Object.assign({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'mail.message', + arch: ` + <form> + <widget name="notification_alert"/> + </form> + `, + }, params)); + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.skip('notification_alert widget: display blocked notification alert', async function (assert) { + // FIXME: Test should work, but for some reasons OWL always flags the + // component as not mounted, even though it is in the DOM and it's state + // is good for rendering... task-227947 + assert.expect(1); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'denied', + }, + }, + }, + }); + + assert.containsOnce( + document.body, + '.o_notification_alert', + "Blocked notification alert should be displayed" + ); +}); + +QUnit.test('notification_alert widget: no notification alert when granted', async function (assert) { + assert.expect(1); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'granted', + }, + }, + }, + }); + + assert.containsNone( + document.body, + '.o_notification_alert', + "Blocked notification alert should not be displayed" + ); +}); + +QUnit.test('notification_alert widget: no notification alert when default', async function (assert) { + assert.expect(1); + + await this.start({ + env: { + browser: { + Notification: { + permission: 'default', + }, + }, + }, + }); + + assert.containsNone( + document.body, + '.o_notification_alert', + "Blocked notification alert should not be displayed" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/xml/activity.xml b/addons/mail/static/src/xml/activity.xml new file mode 100644 index 00000000..487eb210 --- /dev/null +++ b/addons/mail/static/src/xml/activity.xml @@ -0,0 +1,130 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.activity_items"> + <div class="o_thread_date_separator o_border_dashed" data-toggle="collapse" data-target="#o_chatter_planned_activities"> + <a role="button" class="o_thread_date btn"> + <i class="fa fa-fw fa-caret-down"/> + Planned activities + <small class="o_chatter_planned_activities_summary ml8"> + <span class="badge rounded-circle badge-danger"><t t-esc="nbOverdueActivities"/></span> + <span class="badge rounded-circle badge-warning"><t t-esc="nbTodayActivities"/></span> + <span class="badge rounded-circle badge-success"><t t-esc="nbPlannedActivities"/></span> + </small> + </a> + </div> + <div id="o_chatter_planned_activities" class="collapse show"> + <t t-foreach="activities" t-as="activity"> + <div class="o_thread_message" style="margin-bottom: 10px"> + <div class="o_thread_message_sidebar"> + <div class="o_avatar_stack"> + <img t-attf-src="/web/image#{activity.user_id[0] >= 0 ? ('/res.users/' + activity.user_id[0] + '/image_128') : ''}" class="o_thread_message_avatar rounded-circle mb8" t-att-title="activity.user_id[1]" t-att-alt="activity.user_id[1]"/> + <i t-att-class="'o_avatar_icon fa ' + activity.icon + ' bg-' + (activity.state == 'planned'? 'success' : (activity.state == 'today'? 'warning' : 'danger')) + '-full'" + t-att-title="activity.activity_type_id[1]"/> + </div> + </div> + <div class="o_thread_message_core"> + <div class="o_mail_info text-muted"> + <strong><span t-attf-class="o_activity_date o_activity_color_#{activity.state}"><t t-esc="activity.label_delay" /></span></strong>: + <strong t-if="activity.summary" class="o_activity_summary"> “<t t-esc="activity.summary"/>”</strong> + <strong t-if="!activity.summary"> <t t-esc="activity.activity_type_id[1]" /></strong> + <em> for </em> + <t t-esc="activity.user_id[1]" /> + <a class="btn btn-link btn-info text-muted collapsed o_activity_info ml4" role="button" data-toggle="collapse" t-attf-data-target="#o_chatter_activity_info_#{activity.id}"> + <i class="fa fa-info-circle" role="img" aria-label="Info" title="Info"></i> + </a> + <div class="o_thread_message_collapse collapse" t-attf-id="o_chatter_activity_info_#{activity.id}"> + <dl class="dl-horizontal"> + <dt>Activity type</dt> + <dd class="mb8"> + <t t-esc="activity.activity_type_id[1]"/> + </dd> + <dt>Created on</dt> + <dd class="mb8"> + <t t-esc="activity.create_date.format(datetimeFormat)"/> + by + <img t-attf-src="/web/image#{activity.create_uid[0] >= 0 ? ('/res.users/' + activity.create_uid[0] + '/image_128') : ''}" + height="18" width="18" + class="o_object_fit_cover rounded-circle mr4" + t-att-title="activity.create_uid[1]" + t-att-alt="activity.create_uid[1]"/> + <b><t t-esc="activity.create_uid[1]"/></b> + </dd> + <dt>Assigned to</dt> + <dd class="mb8"> + <img t-attf-src="/web/image#{activity.user_id[0] >= 0 ? ('/res.users/' + activity.user_id[0] + '/image_128') : ''}" height="18" width="18" class="o_object_fit_cover rounded-circle mr4" t-att-title="activity.user_id[1]" t-att-alt="activity.user_id[1]"/> + <b><t t-esc="activity.user_id[1]"/></b> + <em>, due on </em><span t-attf-class="o_activity_color_#{activity.state}"><t t-esc="activity.date_deadline.format(dateFormat)"/></span> + </dd> + </dl> + </div> + </div> + <div t-if="activity.note" t-attf-class="o_thread_message_#{activity.activity_decoration ? activity.activity_decoration : 'note'} #{activity.activity_decoration ? 'alert alert-' + activity.activity_decoration : ''}"> + <t t-raw="activity.note"/> + </div> + <t t-if="activity.mail_template_ids && activity.mail_template_ids.length > 0"> + <div class="mt16" t-att-data-activity-id="activity.id" t-att-data-previous-activity-type-id="activity.activity_type_id[0]"> + <t t-foreach="activity.mail_template_ids" t-as="mail_template"> + <div> + <i class="fa fa-envelope-o" aria-label="Mail" title="Mail" role="img"></i> + <span t-esc="mail_template.name"/>: + <span class="o_activity_template_preview" t-att-data-template-id="mail_template.id">Preview</span> + <span class="text-muted">or</span> + <span class="o_activity_template_send" t-att-data-template-id="mail_template.id">Send Now</span> + </div> + </t> + </div> + </t> + <div class="o_thread_message_tools btn-group"> + <t t-call="mail.activity_thread_message_tools"/> + </div> + </div> + </div> + </t> + </div> + </t> + <t t-name="mail.activity_thread_message_tools"> + <div t-if="activity.can_write" class="o_thread_message_tools btn-group"> + <span t-if="activity.activity_category !== 'upload_file'" class="o_mark_as_done" data-toggle="popover" t-att-data-activity-id="activity.id" t-att-data-force-next-activity="activity.force_next" t-att-data-previous-activity-type-id="activity.activity_type_id[0]"> + <a role="button" href="#" class="btn btn-link btn-success text-muted o_activity_link mr8"> + <i class="fa fa-check"/> Mark Done </a> + </span> + <span t-if="activity.activity_category === 'upload_file'" class="o_mark_as_done_upload_file" t-att-data-activity-id="activity.id" t-att-data-force-next-activity="activity.force_next" t-att-data-previous-activity-type-id="activity.activity_type_id[0]" t-att-data-fileupload-id="activity.fileuploadID"> + <a role="button" href="#" class="btn btn-link btn-success text-muted o_activity_link mr8"> + <i class="fa fa-upload"/> Upload Document </a> + </span> + <span t-if="activity.activity_category === 'upload_file'" class="d-none"> + <t t-call="HiddenInputFile"> + <t t-set="fileupload_id" t-value="activity.fileuploadID"/> + <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t> + <input type="hidden" name="model" t-att-value="activity.res_model"/> + <input type="hidden" name="id" t-att-value="activity.res_id"/> + </t> + </span> + <a role="button" href="#" class="btn btn-link btn-secondary text-muted o_edit_activity o_activity_link" t-att-data-activity-id="activity.id"> + <i class="fa fa-pencil"/> Edit + </a> + <a role="button" href="#" class="btn btn-link btn-danger text-muted o_unlink_activity o_activity_link" t-att-data-activity-id="activity.id"> + <i class="fa fa-times"/> Cancel + </a> + </div> + </t> + <t t-name="mail.activity_feedback_form"> + <div> + <textarea class="form-control" rows="3" id="activity_feedback" placeholder="Write Feedback"/> + <div class="mt8"> + <t t-if="!force_next"> + <button type="button" class="btn btn-sm btn-primary o_activity_popover_done_next" t-att-data-previous-activity-type-id="previous_activity_type_id"> + Done & Schedule Next</button> + <button type="button" class="btn btn-sm btn-primary o_activity_popover_done"> + Done</button> + <button type="button" class="btn btn-sm btn-link o_activity_popover_discard"> + Discard</button> + </t> + <t t-else=""> + <button type="button" class="btn btn-sm btn-primary o_activity_popover_done_next"> + Done & Launch Next</button> + </t> + </div> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/xml/activity_view.xml b/addons/mail/static/src/xml/activity_view.xml new file mode 100644 index 00000000..c40d6d73 --- /dev/null +++ b/addons/mail/static/src/xml/activity_view.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + +<t t-name="mail.ActivityViewHeader" owl="1"> + <thead> + <tr> + <th></th> + <th t-foreach="props.activity_types" t-as="type" t-key="type[0]" + class="o_activity_type_cell" t-attf-class="{{ activeFilter.activityTypeId === type[0] ? 'o_activity_filter_' + activeFilter.state : '' }}" + t-att-data-activity-type-id="type[0]" t-attf-width="{{100/props.activity_types.length}}%"> + <div> + <span t-esc="type[1]"/> + <span t-if="type[2].length > 0" class="dropdown pull-right"> + <i class="fa fa-ellipsis-v fa-fw" data-toggle="dropdown"/> + <div class="dropdown-menu"> + <t t-foreach="type[2]" t-as="template" t-key="template.id"> + <div title="This action will send an email." + class="o_template_element o_send_mail_template" + t-att-data-activity-type-id="type[0]" + t-att-data-template-id="template.id" + t-on-click="_onSendMailTemplateClicked"> + <i class="fa fa-envelope fa-fw"/> <t t-esc="template.name"/> + </div> + </t> + </div> + </span> + </div> + <KanbanColumnProgressBarAdapter Component="widgetComponents.KanbanColumnProgressBar" + widgetArgs="[getProgressBarOptions(type[0]), getProgressBarColumnState(type[0])]" + t-if="activityTypeIds.includes(type[0])" + t-on-set-progress-bar-state="_onSetProgressBarState"/> + <div t-else="" class="mt24"/> + </th> + </tr> + </thead> +</t> + +<t t-name="mail.ActivityViewBody" owl="1"> + <tbody> + <t t-foreach="activityResIds" t-as="resId" t-key="resId"> + <t t-call="mail.ActivityViewRow"/> + </t> + </tbody> +</t> + +<t t-name="mail.ActivityViewRow" owl="1"> + <tr class="o_data_row" t-att-data-res-id="resId"> + <t t-set="record" t-value="props.data.find(data => data.res_id === resId)"/> + <td t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state : '' }}"> + <ActivityRecordAdapter Component="widgetComponents.ActivityRecord" + widgetArgs="[record, { qweb: qweb }]"/> + </td> + <t t-foreach="props.activity_types" t-as="type" t-key="type[0]"> + <t t-call="mail.ActivityViewCell"/> + </t> + </tr> +</t> + +<t t-name="mail.ActivityViewCell" owl="1"> + <t t-set="activityGroup" t-value="props.grouped_activities[resId] and props.grouped_activities[resId][type[0]] or {count: 0, ids: [], state: false}"/> + <td t-if="activityGroup.state" t-att-data-res-id="resId" t-att-data-activity-type-id="type[0]" + t-attf-class="o_activity_summary_cell {{activityGroup.state}} {{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state : '' }}"> + <ActivityCellAdapter Component="widgetComponents.ActivityCell" + widgetArgs="['activity_ids', props.getKanbanActivityData(activityGroup, resId)]"/> + </td> + <td t-else="" t-att-data-res-id="resId" t-att-data-activity-type-id="type[0]" + class="o_activity_summary_cell o_activity_empty_cell" + t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state : '' }}" + t-on-click.prevent.stop="_onEmptyCellClicked"> + <i title="Create" class="text-center fa fa-plus"/> + </td> +</t> + +<t t-name="mail.ActivityViewFooter" owl="1"> + <tfoot> + <tr class="o_data_row"> + <td class="o_record_selector p-3" t-on-click.prevent.stop="trigger('schedule_activity')"> + <span class="fa fa-plus pr-2"/><span>Schedule activity</span> + </td> + </tr> + </tfoot> +</t> + +<div t-name="mail.ActivityRenderer" class="o_activity_view" owl="1"> + <t t-if="!props.activity_types.length" t-call="web.NoContentHelper"/> + <table t-else="" class="table-bordered mb-5"> + <t t-call="mail.ActivityViewHeader"/> + <t t-call="mail.ActivityViewBody"/> + <t t-call="mail.ActivityViewFooter"/> + </table> +</div> + +</templates> diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml new file mode 100644 index 00000000..49c0a3bf --- /dev/null +++ b/addons/mail/static/src/xml/composer.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <div t-name="mail.legacy.Composer.emojis" class="o_mail_emoji_container"> + <t t-foreach="emojis" t-as="emoji"> + <span t-att-data-emoji="emoji.sources[0]" class="o_mail_emoji" t-att-title="emoji.description" t-att-aria-label="emoji.description"> + <t t-raw="emoji.unicode"/> + </span> + </t> + </div> + + <t t-name="FieldMany2ManyTagsEmail" t-extend="FieldMany2ManyTag"> + <t t-jquery="[t-attf-class*=badge]" t-operation="replace"> + <div t-attf-class="badge badge-pill dropdown o_tag_color_0 #{el.email.indexOf('@') < 0 ? 'o_tag_error' : ''}" t-att-data-color="color" t-att-data-index="el_index" t-att-data-id="el.id" t-attf-title="Tag color: #{colornames[color]}"> + <span class="o_badge_text" t-att-title="el.email"><t t-esc="el.display_name"/></span> + <a t-if="!readonly" href="#" class="fa fa-times o_delete" title="Delete" aria-label="Delete"/> + </div> + </t> + </t> +</templates> diff --git a/addons/mail/static/src/xml/many2one_avatar_user.xml b/addons/mail/static/src/xml/many2one_avatar_user.xml new file mode 100644 index 00000000..3ef845fd --- /dev/null +++ b/addons/mail/static/src/xml/many2one_avatar_user.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- MailMany2OneAvatar: do not display the display_name in kanban views --> + <t t-name="mail.KanbanMany2OneAvatarUser" t-extend="web.Many2OneAvatar"> + <t t-jquery="img" t-operation="attributes"> + <attribute name="t-att-title">value</attribute> + </t> + <t t-jquery="span" t-operation="replace"/> + </t> + +</templates> diff --git a/addons/mail/static/src/xml/systray.xml b/addons/mail/static/src/xml/systray.xml new file mode 100644 index 00000000..b9612e13 --- /dev/null +++ b/addons/mail/static/src/xml/systray.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates> + + <!-- + @param {mail.systray.ActivityMenu} widget + @param {Object[]} widget.activities + --> + <t t-name="mail.systray.ActivityMenu.Previews"> + <t t-set="activities" t-value="widget._activities"/> + <t t-if="_.isEmpty(activities)"> + <div class="dropdown-item-text text-center o_no_activity"> + <span>No activities planned.</span> + </div> + </t> + <t t-foreach="activities" t-as="activity"> + <div class="o_mail_preview o_systray_activity" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" t-att-data-domain="activity.domain" data-filter='my'> + <div t-if="activity.icon" class="o_mail_preview_image o_mail_preview_app"> + <img t-att-src="activity.icon" alt="Activity"/> + </div> + <div class="o_preview_info"> + <div class="o_preview_title"> + <span class="o_preview_name"> + <t t-esc="activity.name"/> + </span> + <div t-if="activity.actions" class="o_mail_activity_action_buttons"> + <t t-foreach="activity.actions" t-as="action"> + <button type="button" + t-att-title="action.name" + t-att-class="'o_mail_activity_action btn btn-link fa ' + action.icon" + t-att-data-action_xmlid="action.action_xmlid" + t-att-data-res_model="activity.model" + t-att-data-model_name="activity.name" + t-att-data-domain="activity.domain"> + </button> + </t> + </div> + </div> + <div t-if="activity and activity.type == 'activity'"> + <button t-if="activity.overdue_count" type="button" class="btn btn-link o_activity_filter_button mr16" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='overdue'><t t-esc="activity.overdue_count"/> Late </button> + <span t-if="!activity.overdue_count" class="o_no_activity mr16">0 Late </span> + <button t-if="activity.today_count" type="button" class="btn btn-link o_activity_filter_button mr16" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='today'> <t t-esc="activity.today_count"/> Today </button> + <span t-if="!activity.today_count" class="o_no_activity mr16">0 Today </span> + <button t-if="activity.planned_count" type="button" class="btn btn-link o_activity_filter_button float-right" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='upcoming_all'> <t t-esc="activity.planned_count"/> Future </button> + <span t-if="!activity.planned_count" class="o_no_activity float-right">0 Future</span> + </div> + </div> + </div> + </t> + </t> + + <t t-name="mail.systray.ActivityMenu"> + <li class="o_mail_systray_item"> + <a class="dropdown-toggle o-no-caret" data-toggle="dropdown" data-display="static" aria-expanded="false" title="Activities" href="#" role="button"> + <i class="fa fa-clock-o" role="img" aria-label="Activities"/> <span class="o_notification_counter badge badge-pill"/> + </a> + <div class="o_mail_systray_dropdown dropdown-menu dropdown-menu-right" role="menu"> + <div class="o_mail_systray_dropdown_items"/> + </div> + </li> + </t> +</templates> diff --git a/addons/mail/static/src/xml/text_emojis.xml b/addons/mail/static/src/xml/text_emojis.xml new file mode 100644 index 00000000..4f333ba9 --- /dev/null +++ b/addons/mail/static/src/xml/text_emojis.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <div t-name="mail.EmojisDropdown" class="o_mail_emojis_dropdown o_mail_add_emoji dropdown position-relative"> + <button class="btn btn-block dropdown-toggle px-3 py-1" + type="button" + data-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false" + title="Add an emoji"> + <i class="fa fa-smile-o"/> + </button> + <div class="dropdown-menu dropdown-menu-right border-0 p-2" style="width: 406px"> + <t t-call="mail.legacy.Composer.emojis"> + <t t-set="emojis" t-value="widget.emojis"></t> + </t> + </div> + </div> +</templates> diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml new file mode 100644 index 00000000..5a3205ed --- /dev/null +++ b/addons/mail/static/src/xml/thread.xml @@ -0,0 +1,102 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- + extends the debug mode menu to allow access to the attachment list view of the current record. + --> + <t t-extend="WebClient.DebugManager.View"> + <t t-jquery="a[data-action='get_metadata']" t-operation="after"> + <a role="menuitem" href="#" data-action="getMailMessages" class="dropdown-item">Manage Messages</a> + </t> + </t> + <!-- + @param {mail.DocumentViewer} widget + --> + <t t-name="DocumentViewer.Content"> + <div class="o_viewer_content"> + <t t-set="model" t-value="widget.modelName"/> + <div class="o_viewer-header"> + <span class="o_image_caption"> + <i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/> + <i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/> + <i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/> + <t t-esc="widget.activeAttachment.name"/> + <a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a> + </span> + <a role="button" class="o_close_btn float-right" href="#" aria-label="Close" title="Close">×</a> + </div> + <div class="o_viewer_img_wrapper"> + <div class="o_viewer_zoomer"> + <t t-if="widget.activeAttachment.fileType === 'image'"> + <div class="o_loading_img text-center"> + <i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/> + </div> + <t t-set="unique" t-value="widget.activeAttachment.checksum ? widget.activeAttachment.checksum.slice(-8) : ''"/> + <img class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=#{unique}&model=#{model}" alt="Viewer"/> + </t> + <iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}%26filename%3D#{window.encodeURIComponent(widget.activeAttachment.name)}" /> + <iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model=#{model}" /> + <iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/> + <video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls"> + <source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/> + </video> + </div> + </div> + <div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar"> + <div class="btn-group" role="group"> + <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a> + <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a> + <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_out disabled" data-toggle="tooltip" title="Zoom Out"><i class="fa fa-fw fa-minus" role="img" aria-label="Zoom Out"/></a> + </div> + <div class="btn-group" role="group"> + <a role="button" href="#" class="o_viewer_toolbar_btn btn o_rotate" data-toggle="tooltip" title="Rotate"><i class="fa fa-fw fa-repeat" role="img" aria-label="Rotate"/></a> + </div> + <div class="btn-group" role="group"> + <a role="button" href="#" class="o_viewer_toolbar_btn btn o_print_btn" data-toggle="tooltip" title="Print"><i class="fa fa-fw fa-print" role="img" aria-label="Print"/></a> + <a role="button" href="#" class="o_viewer_toolbar_btn btn o_download_btn" data-toggle="tooltip" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a> + </div> + </div> + </div> + </t> + + <!-- + @param {mail.DocumentViewer} widget + --> + <t t-name="DocumentViewer"> + <div class="modal o_modal_fullscreen" tabindex="-1" data-keyboard="false" role="dialog"> + <t class="o_document_viewer_content_call" t-call="DocumentViewer.Content"/> + + <t t-if="widget.attachment.length !== 1"> + <a class="arrow arrow-left move_previous" href="#"> + <span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/> + </a> + <a class="arrow arrow-right move_next" href="#"> + <span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/> + </a> + </t> + </div> + </t> + + <!-- + @param {string} src + --> + <t t-name="PrintImage"> + <html> + <head> + <script> + function onload_img() { + setTimeout('print_img()', 10); + } + function print_img() { + window.print(); + window.close(); + } + </script> + </head> + <body onload='onload_img()'> + <img t-att-src='src' alt=""/> + </body> + </html> + </t> + +</templates> diff --git a/addons/mail/static/src/xml/web_kanban_activity.xml b/addons/mail/static/src/xml/web_kanban_activity.xml new file mode 100644 index 00000000..e6d82e37 --- /dev/null +++ b/addons/mail/static/src/xml/web_kanban_activity.xml @@ -0,0 +1,114 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + +<t t-name="mail.KanbanActivity"> + <div class="o_kanban_inline_block dropdown o_mail_activity"> + <a class="dropdown-toggle o-no-caret o_activity_btn" data-toggle="dropdown" role="button"> + <!-- span classes are generated dynamically (see _render) --> + <span t-att-title="widget.selection[widget.activityState]" role="img" t-att-aria-label="widget.selection[widget.activity_state]"/> + </a> + <div class="dropdown-menu o_activity" role="menu"/> + </div> +</t> + +<t t-name="mail.ListActivity" t-extend="mail.KanbanActivity"> + <t t-jquery=".o_mail_activity" t-operation="append"> + <span class="o_activity_summary"/> + </t> +</t> + +<t t-name="mail.KanbanActivityLoading"> + <div class="dropdown-item text-center o_no_activity"> + <span class="fa fa-spinner fa-spin fa-2x" role="img" aria-label="Loading..." title="Loading..."/> + </div> +</t> + +<t t-name="mail.KanbanActivityDropdown"> + <span role="menuitem" t-if="_.isEmpty(records)" class="dropdown-item-text text-center o_no_activity"> + <i>Schedule activities to help you get things done.</i> + </span> + <div t-else="" aria-haspopup="true" role="menu" class="o_activity_log_container dropdown-item bg-100 p-0"> + <ul class="o_activity_log list-group list-group-flush mb-2" role="menu"> + <t t-foreach="_.keys(records)" t-as="key"> + <t t-set="logs" t-value="records[key]" /> + <t t-set="contextual_class" t-value="key == 'planned' ? 'success' : (key == 'today' ? 'warning' : 'danger') "/> + + <li role="menuitem" t-attf-class="o_activity_label list-group-item list-group-item list-group-item-light d-flex justify-content-between align-items-center o_activity_color_{{key}} {{!key_first ? 'mt-2' : ''}}"> + <strong><t t-esc="selection[key]"/></strong> + <span t-attf-class="badge badge-pill badge-{{contextual_class}} border-0 mr-0"><t t-esc="logs.length"/></span> + </li> + <t t-foreach="logs" t-as="log"> + <t t-set="edit_class" t-value="'o_edit_activity'"/> + <t t-if="log.force_next"> + <t t-set="edit_class" t-value=""/> + </t> + <t class="activities_list_group_item"> + <t t-call="mail.activities-list-group-item"/> + </t> + <li t-attf-id="o_activity_form_{{log.id}}" class="o_activity_form list-group-item border-top-0 py-0 mb-2 collapse"></li> + </t> + </t> + </ul> + </div> + <div class="dropdown-divider m-0"/> + <div role="menuitem" class="o_schedule_activity dropdown-header p-0 text-center"> + <button class="btn btn-secondary btn-block p-3"> + <i class="fa fa-plus fa-fw"></i><strong>Schedule an activity</strong> + </button> + </div> +</t> + +<t t-name="mail.activities-list-group-item"> + <li t-attf-class="list-group-item o_log_activity d-flex #{log_last ? 'border-bottom' : ''}" role="menuitem"> + <div t-attf-class="o_activity_title o_log_activity #{edit_class}" t-att-data-activity-id="log.id"> + <div t-attf-class="o_activity_title_entry o_mail_activity {{! log.force_next ? 'align-items-center' : 'mb-1'}}"> + <span t-attf-class="fa #{log.icon ? log.icon : 'fa-bell' } fa-fw mr-2 text-center text-muted" role="img" aria-label="Log" title="Log"/> + <strong class="text-dark o_activity_summary"><t t-esc="log.title_action or log.summary or log.activity_type_id[1]"/></strong> + <button t-if="! log.force_next and log.can_write" class="btn btn-sm btn-link py-0 o_edit_button"><i class="fa fa-pencil"/></button> + </div> + <div class="o_activity_title_entry mt-1" t-if="log.state != 'today'"> + <span class="fa fa-clock-o fa-fw mr-2 text-center text-muted" role="img" aria-label="Deadline" title="Deadline"/> + <small t-if="log.user_id[0] !== session.uid and log.mail_template_ids" class="mr-1"><t t-esc="log.user_id[1]"/> -</small> + <small t-att-title="log.date_deadline"><t t-esc="log.label_delay" /></small> + </div> + <t t-if="log.mail_template_ids"> + <div t-foreach="log.mail_template_ids" t-as="mail_template" class="o_activity_title_entry mt-2" t-att-data-activity-id="log.id" t-att-data-force-next-activity="log.force_next" t-att-data-previous-activity-type-id="log.activity_type_id[0]"> + <i class="fa fa-envelope-o fa-fw mr-2 text-center text-muted" aria-label="Mail" title="Mail" role="img"></i> + <small> + <div class="mb-1" t-esc="mail_template.name + ':'"/> + <a class="o_activity_template_preview" t-att-data-template-id="mail_template.id" href="#"><b>Preview</b></a> + <small>or</small> + <a class="o_activity_template_send" t-att-data-template-id="mail_template.id" href="#"><b>Send Now</b></a> + </small> + </div> + </t> + </div> + <div t-if="log.can_write" class="flex-grow-1 text-right"> + <t t-if="log.activity_category === 'upload_file'"> + <a t-att-data-force-next-activity="log.force_next" + t-att-data-previous-activity-type-id="log.activity_type_id[0]" + t-att-data-activity-id="log.id" + class="o_mark_as_done_upload_file o_activity_link o_activity_link_kanban fa fa-upload" + title="Upload file" role="img" t-att-data-fileupload-id="log.fileuploadID"/> + <span class="d-none"> + <t t-call="HiddenInputFile"> + <t t-set="fileupload_id" t-value="log.fileuploadID"/> + <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t> + <input type="hidden" name="model" t-att-value="log.res_model"/> + <input type="hidden" name="id" t-att-value="log.res_id"/> + </t> + </span> + </t> + <t t-else=""> + <a t-att-data-force-next-activity="log.force_next" + t-att-data-previous-activity-type-id="log.activity_type_id[0]" + t-att-data-activity-id="log.id" + t-attf-href="#o_mark_done_form{{log.id}}" + class="o_mark_as_done o_activity_link o_activity_link_kanban fa fa-check-circle" + data-toggle="collapse" title="Mark as done" role="img" aria-label="Mark as done"/> + </t> + </div> + </li> +</t> + +</templates> diff --git a/addons/mail/static/tests/activity_tests.js b/addons/mail/static/tests/activity_tests.js new file mode 100644 index 00000000..384815c2 --- /dev/null +++ b/addons/mail/static/tests/activity_tests.js @@ -0,0 +1,562 @@ +odoo.define('mail.activity_view_tests', function (require) { +'use strict'; + +var ActivityView = require('mail.ActivityView'); +var testUtils = require('web.test_utils'); +const ActivityRenderer = require('mail.ActivityRenderer'); +const domUtils = require('web.dom'); + +var createActionManager = testUtils.createActionManager; + +var createView = testUtils.createView; + +QUnit.module('mail', {}, function () { +QUnit.module('activity view', { + beforeEach: function () { + this.data = { + task: { + fields: { + id: {string: 'ID', type: 'integer'}, + foo: {string: "Foo", type: "char"}, + activity_ids: { + string: 'Activities', + type: 'one2many', + relation: 'mail.activity', + relation_field: 'res_id', + }, + }, + records: [ + {id: 13, foo: 'Meeting Room Furnitures', activity_ids: [1]}, + {id: 30, foo: 'Office planning', activity_ids: [2, 3]}, + ], + }, + partner: { + fields: { + display_name: { string: "Displayed name", type: "char" }, + }, + records: [{ + id: 2, + display_name: "first partner", + }] + }, + 'mail.activity': { + fields: { + res_id: { string: 'Related document id', type: 'integer' }, + activity_type_id: { string: "Activity type", type: "many2one", relation: "mail.activity.type" }, + display_name: { string: "Display name", type: "char" }, + date_deadline: { string: "Due Date", type: "date" }, + can_write: { string: "Can write", type: "boolean" }, + state: { + string: 'State', + type: 'selection', + selection: [['overdue', 'Overdue'], ['today', 'Today'], ['planned', 'Planned']], + }, + mail_template_ids: { string: "Mail templates", type: "many2many", relation: "mail.template" }, + user_id: { string: "Assigned to", type: "many2one", relation: 'partner' }, + }, + records:[ + { + id: 1, + res_id: 13, + display_name: "An activity", + date_deadline: moment().add(3, "days").format("YYYY-MM-DD"), // now + can_write: true, + + state: "planned", + activity_type_id: 1, + mail_template_ids: [8, 9], + user_id:2, + },{ + id: 2, + res_id: 30, + display_name: "An activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + activity_type_id: 1, + mail_template_ids: [8, 9], + user_id:2, + },{ + id: 3, + res_id: 30, + display_name: "An activity", + date_deadline: moment().subtract(2, "days").format("YYYY-MM-DD"), // now + can_write: true, + state: "overdue", + activity_type_id: 2, + mail_template_ids: [], + user_id:2, + } + ], + }, + 'mail.template': { + fields: { + name: { string: "Name", type: "char" }, + }, + records: [ + { id: 8, name: "Template1" }, + { id: 9, name: "Template2" }, + ], + }, + 'mail.activity.type': { + fields: { + mail_template_ids: { string: "Mail templates", type: "many2many", relation: "mail.template" }, + name: { string: "Name", type: "char" }, + }, + records: [ + { id: 1, name: "Email", mail_template_ids: [8, 9]}, + { id: 2, name: "Call" }, + { id: 3, name: "Call for Demo" }, + { id: 4, name: "To Do" }, + ], + }, + }; + } +}); + +var activityDateFormat = function (date) { + return date.toLocaleDateString(moment().locale(), { day: 'numeric', month: 'short' }); +}; + +QUnit.test('activity view: simple activity rendering', async function (assert) { + assert.expect(14); + var activity = await createView({ + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + intercepts: { + do_action: function (event) { + assert.deepEqual(event.data.action, { + context: { + default_res_id: 30, + default_res_model: "task", + default_activity_type_id: 3, + }, + res_id: false, + res_model: "mail.activity", + target: "new", + type: "ir.actions.act_window", + view_mode: "form", + view_type: "form", + views: [[false, "form"]] + }, + "should do a do_action with correct parameters"); + event.data.options.on_close(); + }, + }, + }); + + assert.containsOnce(activity, 'table', + 'should have a table'); + var $th1 = activity.$('table thead tr:first th:nth-child(2)'); + assert.containsOnce($th1, 'span:first:contains(Email)', 'should contain "Email" in header of first column'); + assert.containsOnce($th1, '.o_kanban_counter', 'should contain a progressbar in header of first column'); + assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:first'), 'data-original-title', '1 Planned', + 'the counter progressbars should be correctly displayed'); + assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:nth-child(2)'), 'data-original-title', '1 Today', + 'the counter progressbars should be correctly displayed'); + var $th2 = activity.$('table thead tr:first th:nth-child(3)'); + assert.containsOnce($th2, 'span:first:contains(Call)', 'should contain "Call" in header of second column'); + assert.hasAttrValue($th2.find('.o_kanban_counter_progress .progress-bar:nth-child(3)'), 'data-original-title', '1 Overdue', + 'the counter progressbars should be correctly displayed'); + assert.containsNone(activity, 'table thead tr:first th:nth-child(4) .o_kanban_counter', + 'should not contain a progressbar in header of 3rd column'); + assert.ok(activity.$('table tbody tr:first td:first:contains(Office planning)').length, + 'should contain "Office planning" in first colum of first row'); + assert.ok(activity.$('table tbody tr:nth-child(2) td:first:contains(Meeting Room Furnitures)').length, + 'should contain "Meeting Room Furnitures" in first colum of second row'); + + var today = activityDateFormat(new Date()); + + assert.ok(activity.$('table tbody tr:first td:nth-child(2).today .o_closest_deadline:contains(' + today + ')').length, + 'should contain an activity for today in second cell of first line ' + today); + var td = 'table tbody tr:nth-child(1) td.o_activity_empty_cell'; + assert.containsN(activity, td, 2, 'should contain an empty cell as no activity scheduled yet.'); + + // schedule an activity (this triggers a do_action) + await testUtils.fields.editAndTrigger(activity.$(td + ':first'), null, ['mouseenter', 'click']); + assert.containsOnce(activity, 'table tfoot tr .o_record_selector', + 'should contain search more selector to choose the record to schedule an activity for it'); + + activity.destroy(); +}); + +QUnit.test('activity view: no content rendering', async function (assert) { + assert.expect(2); + + // reset incompatible setup + this.data['mail.activity'].records = []; + this.data.task.records.forEach(function (task) { + task.activity_ids = false; + }); + this.data['mail.activity.type'].records = []; + + var activity = await createView({ + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + }); + + assert.containsOnce(activity, '.o_view_nocontent', + "should display the no content helper"); + assert.strictEqual(activity.$('.o_view_nocontent .o_view_nocontent_empty_folder').text().trim(), + "No data to display", + "should display the no content helper text"); + + activity.destroy(); +}); + +QUnit.test('activity view: batch send mail on activity', async function (assert) { + assert.expect(6); + var activity = await createView({ + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + mockRPC: function(route, args) { + if (args.method === 'activity_send_mail'){ + assert.step(JSON.stringify(args.args)); + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + }); + assert.notOk(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, + 'dropdown shouldn\'t be displayed'); + + testUtils.dom.click(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v')); + assert.ok(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, + 'dropdown should have appeared'); + + testUtils.dom.click(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template2)')); + assert.notOk(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, + 'dropdown shouldn\'t be displayed'); + + testUtils.dom.click(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v')); + testUtils.dom.click(activity.$('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template1)')); + assert.verifySteps([ + '[[13,30],9]', // send mail template 9 on tasks 13 and 30 + '[[13,30],8]', // send mail template 8 on tasks 13 and 30 + ]); + + activity.destroy(); +}); + +QUnit.test('activity view: activity widget', async function (assert) { + assert.expect(16); + + const params = { + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>'+ + '</activity>', + mockRPC: function(route, args) { + if (args.method === 'activity_send_mail'){ + assert.deepEqual([[30],8],args.args, "Should send template 8 on record 30"); + assert.step('activity_send_mail'); + return Promise.resolve(); + } + if (args.method === 'action_feedback_schedule_next'){ + assert.deepEqual([[3]],args.args, "Should execute action_feedback_schedule_next on activity 3 only "); + assert.equal(args.kwargs.feedback, "feedback2"); + assert.step('action_feedback_schedule_next'); + return Promise.resolve({serverGeneratedAction: true}); + } + return this._super.apply(this, arguments); + }, + intercepts: { + do_action: function (ev) { + var action = ev.data.action; + if (action.serverGeneratedAction) { + assert.step('serverGeneratedAction'); + } else if (action.res_model === 'mail.compose.message') { + assert.deepEqual({ + default_model: "task", + default_res_id: 30, + default_template_id: 8, + default_use_template: true, + force_email: true + }, action.context); + assert.step("do_action_compose"); + } else if (action.res_model === 'mail.activity') { + assert.deepEqual({ + "default_res_id": 30, + "default_res_model": "task" + }, action.context); + assert.step("do_action_activity"); + } else { + assert.step("Unexpected action"); + } + }, + }, + }; + + var activity = await createView(params); + var today = activity.$('table tbody tr:first td:nth-child(2).today'); + var dropdown = today.find('.dropdown-menu.o_activity'); + + await testUtils.dom.click(today.find('.o_closest_deadline')); + assert.hasClass(dropdown,'show', "dropdown should be displayed"); + assert.ok(dropdown.find('.o_activity_color_today:contains(Today)').length, "Title should be today"); + assert.ok(dropdown.find('.o_activity_title_entry[data-activity-id="2"]:first div:contains(template8)').length, + "template8 should be available"); + assert.ok(dropdown.find('.o_activity_title_entry[data-activity-id="2"]:eq(1) div:contains(template9)').length, + "template9 should be available"); + + await testUtils.dom.click(dropdown.find('.o_activity_title_entry[data-activity-id="2"]:first .o_activity_template_preview')); + await testUtils.dom.click(dropdown.find('.o_activity_title_entry[data-activity-id="2"]:first .o_activity_template_send')); + var overdue = activity.$('table tbody tr:first td:nth-child(3).overdue'); + await testUtils.dom.click(overdue.find('.o_closest_deadline')); + dropdown = overdue.find('.dropdown-menu.o_activity'); + assert.notOk(dropdown.find('.o_activity_title div div div:first span').length, + "No template should be available"); + + await testUtils.dom.click(dropdown.find('.o_schedule_activity')); + await testUtils.dom.click(overdue.find('.o_closest_deadline')); + await testUtils.dom.click(dropdown.find('.o_mark_as_done')); + dropdown.find('#activity_feedback').val("feedback2"); + + await testUtils.dom.click(dropdown.find('.o_activity_popover_done_next')); + assert.verifySteps([ + "do_action_compose", + "activity_send_mail", + "do_action_activity", + "action_feedback_schedule_next", + "serverGeneratedAction" + ]); + + activity.destroy(); +}); +QUnit.test('activity view: no group_by_menu and no comparison_menu', async function (assert) { + assert.expect(4); + + var actionManager = await createActionManager({ + actions: [{ + id: 1, + name: 'Task Action', + res_model: 'task', + type: 'ir.actions.act_window', + views: [[false, 'activity']], + }], + archs: { + 'task,false,activity': '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + 'task,false,search': '<search></search>', + }, + data: this.data, + session: { + user_context: {lang: 'zz_ZZ'}, + }, + mockRPC: function(route, args) { + if (args.method === 'get_activity_data') { + assert.deepEqual(args.kwargs.context, {lang: 'zz_ZZ'}, + 'The context should have been passed'); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction(1); + + assert.containsN(actionManager, '.o_search_options .o_dropdown button:visible', 2, + "only two elements should be available in view search"); + assert.isVisible(actionManager.$('.o_search_options .o_dropdown.o_filter_menu > button'), + "filter should be available in view search"); + assert.isVisible(actionManager.$('.o_search_options .o_dropdown.o_favorite_menu > button'), + "favorites should be available in view search"); + actionManager.destroy(); +}); + +QUnit.test('activity view: search more to schedule an activity for a record of a respecting model', async function (assert) { + assert.expect(5); + _.extend(this.data.task.fields, { + name: { string: "Name", type: "char" }, + }); + this.data.task.records[2] = { id: 31, name: "Task 3" }; + var activity = await createView({ + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>' + + '</activity>', + archs: { + "task,false,list": '<tree string="Task"><field name="name"/></tree>', + "task,false,search": '<search></search>', + }, + mockRPC: function(route, args) { + if (args.method === 'name_search') { + args.kwargs.name = "Task"; + } + return this._super.apply(this, arguments); + }, + intercepts: { + do_action: function (ev) { + assert.step('doAction'); + var expectedAction = { + context: { + default_res_id: { id: 31, display_name: undefined }, + default_res_model: "task", + }, + name: "Schedule Activity", + res_id: false, + res_model: "mail.activity", + target: "new", + type: "ir.actions.act_window", + view_mode: "form", + views: [[false, "form"]], + }; + assert.deepEqual(ev.data.action, expectedAction, + "should execute an action with correct params"); + ev.data.options.on_close(); + }, + }, + }); + + assert.containsOnce(activity, 'table tfoot tr .o_record_selector', + 'should contain search more selector to choose the record to schedule an activity for it'); + await testUtils.dom.click(activity.$('table tfoot tr .o_record_selector')); + // search create dialog + var $modal = $('.modal-lg'); + assert.strictEqual($modal.find('.o_data_row').length, 3, "all tasks should be available to select"); + // select a record to schedule an activity for it (this triggers a do_action) + testUtils.dom.click($modal.find('.o_data_row:last')); + assert.verifySteps(['doAction']); + + activity.destroy(); +}); + +QUnit.test('Activity view: discard an activity creation dialog', async function (assert) { + assert.expect(2); + + var actionManager = await createActionManager({ + actions: [{ + id: 1, + name: 'Task Action', + res_model: 'task', + type: 'ir.actions.act_window', + views: [[false, 'activity']], + }], + archs: { + 'task,false,activity': ` + <activity string="Task"> + <templates> + <div t-name="activity-box"> + <field name="foo"/> + </div> + </templates> + </activity>`, + 'task,false,search': '<search></search>', + 'mail.activity,false,form': ` + <form> + <field name="display_name"/> + <footer> + <button string="Discard" class="btn-secondary" special="cancel"/> + </footer> + </form>` + }, + data: this.data, + intercepts: { + do_action(ev) { + actionManager.doAction(ev.data.action, ev.data.options); + } + }, + async mockRPC(route, args) { + if (args.method === 'check_access_rights') { + return true; + } + return this._super(...arguments); + }, + }); + await actionManager.doAction(1); + + await testUtils.dom.click(actionManager.$('.o_activity_view .o_data_row .o_activity_empty_cell')[0]); + assert.containsOnce( + $, + '.modal.o_technical_modal.show', + "Activity Modal should be opened"); + + await testUtils.dom.click($('.modal.o_technical_modal.show button[special="cancel"]')); + assert.containsNone( + $, + '.modal.o_technical_modal.show', + "Activity Modal should be closed"); + + actionManager.destroy(); +}); + +QUnit.test("Activity view: on_destroy_callback doesn't crash", async function (assert) { + assert.expect(3); + + const params = { + View: ActivityView, + model: 'task', + data: this.data, + arch: '<activity string="Task">' + + '<templates>' + + '<div t-name="activity-box">' + + '<field name="foo"/>' + + '</div>' + + '</templates>'+ + '</activity>', + }; + + ActivityRenderer.patch('test_mounted_unmounted', T => + class extends T { + mounted() { + assert.step('mounted'); + } + willUnmount() { + assert.step('willUnmount'); + } + }); + + const activity = await createView(params); + domUtils.detach([{widget: activity}]); + + assert.verifySteps([ + 'mounted', + 'willUnmount' + ]); + + ActivityRenderer.unpatch('test_mounted_unmounted'); + activity.destroy(); +}); + +}); +}); diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js new file mode 100644 index 00000000..006b9ff5 --- /dev/null +++ b/addons/mail/static/tests/chatter_tests.js @@ -0,0 +1,568 @@ +odoo.define('mail.chatter_tests', function (require) { +"use strict"; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +var FormView = require('web.FormView'); +var ListView = require('web.ListView'); +var testUtils = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('Chatter', { + beforeEach: function () { + beforeEach(this); + + this.data['res.partner'].records.push({ id: 11, im_status: 'online' }); + this.data['mail.activity.type'].records.push( + { id: 1, name: "Type 1" }, + { id: 2, name: "Type 2" }, + { id: 3, name: "Type 3", category: 'upload_file' }, + { id: 4, name: "Exception", decoration_type: "warning", icon: "fa-warning" } + ); + this.data['ir.attachment'].records.push( + { + id: 1, + mimetype: 'image/png', + name: 'filename.jpg', + res_id: 7, + res_model: 'res.users', + type: 'url', + }, + { + id: 2, + mimetype: "application/x-msdos-program", + name: "file2.txt", + res_id: 7, + res_model: 'res.users', + type: 'binary', + }, + { + id: 3, + mimetype: "application/x-msdos-program", + name: "file3.txt", + res_id: 5, + res_model: 'res.users', + type: 'binary', + }, + ); + Object.assign(this.data['res.users'].fields, { + activity_exception_decoration: { + string: 'Decoration', + type: 'selection', + selection: [['warning', 'Alert'], ['danger', 'Error']], + }, + activity_exception_icon: { + string: 'icon', + type: 'char', + }, + activity_ids: { + string: 'Activities', + type: 'one2many', + relation: 'mail.activity', + relation_field: 'res_id', + }, + activity_state: { + string: 'State', + type: 'selection', + selection: [['overdue', 'Overdue'], ['today', 'Today'], ['planned', 'Planned']], + }, + activity_summary: { + string: "Next Activity Summary", + type: 'char', + }, + activity_type_icon: { + string: "Activity Type Icon", + type: 'char', + }, + activity_type_id: { + string: "Activity type", + type: "many2one", + relation: "mail.activity.type", + }, + foo: { string: "Foo", type: "char", default: "My little Foo Value" }, + message_attachment_count: { + string: 'Attachment count', + type: 'integer', + }, + message_follower_ids: { + string: "Followers", + type: "one2many", + relation: 'mail.followers', + relation_field: "res_id", + }, + message_ids: { + string: "messages", + type: "one2many", + relation: 'mail.message', + relation_field: "res_id", + }, + }); + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('list activity widget with no activity', async function (assert) { + assert.expect(5); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: '<list><field name="activity_ids" widget="list_activity"/></list>', + mockRPC: function (route) { + assert.step(route); + return this._super(...arguments); + }, + session: { uid: 2 }, + }); + + assert.containsOnce(list, '.o_mail_activity .o_activity_color_default'); + assert.strictEqual(list.$('.o_activity_summary').text(), ''); + + assert.verifySteps([ + '/web/dataset/search_read', + '/mail/init_messaging', + ]); + + list.destroy(); +}); + +QUnit.test('list activity widget with activities', async function (assert) { + assert.expect(7); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + Object.assign(currentUser, { + activity_ids: [1, 4], + activity_state: 'today', + activity_summary: 'Call with Al', + activity_type_id: 3, + activity_type_icon: 'fa-phone', + }); + + this.data['res.users'].records.push({ + id: 44, + activity_ids: [2], + activity_state: 'planned', + activity_summary: false, + activity_type_id: 2, + }); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: '<list><field name="activity_ids" widget="list_activity"/></list>', + mockRPC: function (route) { + assert.step(route); + return this._super(...arguments); + }, + }); + + const $firstRow = list.$('.o_data_row:first'); + assert.containsOnce($firstRow, '.o_mail_activity .o_activity_color_today.fa-phone'); + assert.strictEqual($firstRow.find('.o_activity_summary').text(), 'Call with Al'); + + const $secondRow = list.$('.o_data_row:nth(1)'); + assert.containsOnce($secondRow, '.o_mail_activity .o_activity_color_planned.fa-clock-o'); + assert.strictEqual($secondRow.find('.o_activity_summary').text(), 'Type 2'); + + assert.verifySteps([ + '/web/dataset/search_read', + '/mail/init_messaging', + ]); + + list.destroy(); +}); + +QUnit.test('list activity widget with exception', async function (assert) { + assert.expect(5); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + Object.assign(currentUser, { + activity_ids: [1], + activity_state: 'today', + activity_summary: 'Call with Al', + activity_type_id: 3, + activity_exception_decoration: 'warning', + activity_exception_icon: 'fa-warning', + }); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: '<list><field name="activity_ids" widget="list_activity"/></list>', + mockRPC: function (route) { + assert.step(route); + return this._super(...arguments); + }, + }); + + assert.containsOnce(list, '.o_activity_color_today.text-warning.fa-warning'); + assert.strictEqual(list.$('.o_activity_summary').text(), 'Warning'); + + assert.verifySteps([ + '/web/dataset/search_read', + '/mail/init_messaging', + ]); + + list.destroy(); +}); + +QUnit.test('list activity widget: open dropdown', async function (assert) { + assert.expect(10); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + Object.assign(currentUser, { + activity_ids: [1, 4], + activity_state: 'today', + activity_summary: 'Call with Al', + activity_type_id: 3, + }); + this.data['mail.activity'].records.push( + { + id: 1, + display_name: "Call with Al", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + user_id: this.data.currentUserId, + create_uid: this.data.currentUserId, + activity_type_id: 3, + }, + { + id: 4, + display_name: "Meet FP", + date_deadline: moment().add(1, 'day').format("YYYY-MM-DD"), // tomorrow + can_write: true, + state: "planned", + user_id: this.data.currentUserId, + create_uid: this.data.currentUserId, + activity_type_id: 1, + } + ); + + const { env, widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: ` + <list> + <field name="foo"/> + <field name="activity_ids" widget="list_activity"/> + </list>`, + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'action_feedback') { + const currentUser = this.data['res.users'].records.find(user => + user.id === env.messaging.currentUser.id + ); + Object.assign(currentUser, { + activity_ids: [4], + activity_state: 'planned', + activity_summary: 'Meet FP', + activity_type_id: 1, + }); + return Promise.resolve(); + } + return this._super(route, args); + }, + intercepts: { + switch_view: () => assert.step('switch_view'), + }, + }); + + assert.strictEqual(list.$('.o_activity_summary').text(), 'Call with Al'); + + // click on the first record to open it, to ensure that the 'switch_view' + // assertion is relevant (it won't be opened as there is no action manager, + // but we'll log the 'switch_view' event) + await testUtils.dom.click(list.$('.o_data_cell:first')); + + // from this point, no 'switch_view' event should be triggered, as we + // interact with the activity widget + assert.step('open dropdown'); + await testUtils.dom.click(list.$('.o_activity_btn span')); // open the popover + await testUtils.dom.click(list.$('.o_mark_as_done:first')); // mark the first activity as done + await testUtils.dom.click(list.$('.o_activity_popover_done')); // confirm + + assert.strictEqual(list.$('.o_activity_summary').text(), 'Meet FP'); + + assert.verifySteps([ + '/web/dataset/search_read', + '/mail/init_messaging', + 'switch_view', + 'open dropdown', + 'activity_format', + 'action_feedback', + 'read', + ]); + + list.destroy(); +}); + +QUnit.test('list activity exception widget with activity', async function (assert) { + assert.expect(3); + + const currentUser = this.data['res.users'].records.find(user => + user.id === this.data.currentUserId + ); + currentUser.activity_ids = [1]; + this.data['res.users'].records.push({ + id: 13, + message_attachment_count: 3, + display_name: "second partner", + foo: "Tommy", + message_follower_ids: [], + message_ids: [], + activity_ids: [2], + activity_exception_decoration: 'warning', + activity_exception_icon: 'fa-warning', + }); + this.data['mail.activity'].records.push( + { + id: 1, + display_name: "An activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + user_id: 2, + create_uid: 2, + activity_type_id: 1, + }, + { + id: 2, + display_name: "An exception activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + user_id: 2, + create_uid: 2, + activity_type_id: 4, + } + ); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'res.users', + data: this.data, + arch: '<tree>' + + '<field name="foo"/>' + + '<field name="activity_exception_decoration" widget="activity_exception"/> ' + + '</tree>', + }); + + assert.containsN(list, '.o_data_row', 2, "should have two records"); + assert.doesNotHaveClass(list.$('.o_data_row:eq(0) .o_activity_exception_cell div'), 'fa-warning', + "there is no any exception activity on record"); + assert.hasClass(list.$('.o_data_row:eq(1) .o_activity_exception_cell div'), 'fa-warning', + "there is an exception on a record"); + + list.destroy(); +}); + +QUnit.module('FieldMany2ManyTagsEmail', { + beforeEach() { + beforeEach(this); + + Object.assign(this.data['res.users'].fields, { + timmy: { string: "pokemon", type: "many2many", relation: 'partner_type' }, + }); + this.data['res.users'].records.push({ + id: 11, + display_name: "first record", + timmy: [], + }); + Object.assign(this.data, { + partner_type: { + fields: { + name: { string: "Partner Type", type: "char" }, + email: { string: "Email", type: "char" }, + }, + records: [], + }, + }); + this.data['partner_type'].records.push( + { id: 12, display_name: "gold", email: 'coucou@petite.perruche' }, + { id: 14, display_name: "silver", email: '' } + ); + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('fieldmany2many tags email', function (assert) { + assert.expect(13); + var done = assert.async(); + + const user11 = this.data['res.users'].records.find(user => user.id === 11); + user11.timmy = [12, 14]; + + // the modals need to be closed before the form view rendering + start({ + hasView: true, + View: FormView, + model: 'res.users', + data: this.data, + res_id: 11, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="display_name"/>' + + '<field name="timmy" widget="many2many_tags_email"/>' + + '</sheet>' + + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'partner_type') { + assert.step(JSON.stringify(args.args[0])); + assert.deepEqual(args.args[1], ['display_name', 'email'], "should read the email"); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner_type,false,form': '<form string="Types"><field name="display_name"/><field name="email"/></form>', + }, + }).then(async function ({ widget: form }) { + // should read it 3 times (1 with the form view, one with the form dialog and one after save) + assert.verifySteps(['[12,14]', '[14]', '[14]']); + await testUtils.nextTick(); + assert.containsN(form, '.o_field_many2manytags[name="timmy"] .badge.o_tag_color_0', 2, + "two tags should be present"); + var firstTag = form.$('.o_field_many2manytags[name="timmy"] .badge.o_tag_color_0').first(); + assert.strictEqual(firstTag.find('.o_badge_text').text(), "gold", + "tag should only show display_name"); + assert.hasAttrValue(firstTag.find('.o_badge_text'), 'title', "coucou@petite.perruche", + "tag should show email address on mouse hover"); + form.destroy(); + done(); + }); + testUtils.nextTick().then(function () { + assert.strictEqual($('.modal-body.o_act_window').length, 1, + "there should be one modal opened to edit the empty email"); + assert.strictEqual($('.modal-body.o_act_window input[name="display_name"]').val(), "silver", + "the opened modal should be a form view dialog with the partner_type 14"); + assert.strictEqual($('.modal-body.o_act_window input[name="email"]').length, 1, + "there should be an email field in the modal"); + + // set the email and save the modal (will render the form view) + testUtils.fields.editInput($('.modal-body.o_act_window input[name="email"]'), 'coucou@petite.perruche'); + testUtils.dom.click($('.modal-footer .btn-primary')); + }); + +}); + +QUnit.test('fieldmany2many tags email (edition)', async function (assert) { + assert.expect(15); + + const user11 = this.data['res.users'].records.find(user => user.id === 11); + user11.timmy = [12]; + + var { widget: form } = await start({ + hasView: true, + View: FormView, + model: 'res.users', + data: this.data, + res_id: 11, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="display_name"/>' + + '<field name="timmy" widget="many2many_tags_email"/>' + + '</sheet>' + + '</form>', + viewOptions: { + mode: 'edit', + }, + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'partner_type') { + assert.step(JSON.stringify(args.args[0])); + assert.deepEqual(args.args[1], ['display_name', 'email'], "should read the email"); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner_type,false,form': '<form string="Types"><field name="display_name"/><field name="email"/></form>', + }, + }); + + assert.verifySteps(['[12]']); + assert.containsOnce(form, '.o_field_many2manytags[name="timmy"] .badge.o_tag_color_0', + "should contain one tag"); + + // add an other existing tag + await testUtils.fields.many2one.clickOpenDropdown('timmy'); + await testUtils.fields.many2one.clickHighlightedItem('timmy'); + + assert.strictEqual($('.modal-body.o_act_window').length, 1, + "there should be one modal opened to edit the empty email"); + assert.strictEqual($('.modal-body.o_act_window input[name="display_name"]').val(), "silver", + "the opened modal in edit mode should be a form view dialog with the partner_type 14"); + assert.strictEqual($('.modal-body.o_act_window input[name="email"]').length, 1, + "there should be an email field in the modal"); + + // set the email and save the modal (will rerender the form view) + await testUtils.fields.editInput($('.modal-body.o_act_window input[name="email"]'), 'coucou@petite.perruche'); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + assert.containsN(form, '.o_field_many2manytags[name="timmy"] .badge.o_tag_color_0', 2, + "should contain the second tag"); + // should have read [14] three times: when opening the dropdown, when opening the modal, and + // after the save + assert.verifySteps(['[14]', '[14]', '[14]']); + + form.destroy(); +}); + +QUnit.test('many2many_tags_email widget can load more than 40 records', async function (assert) { + assert.expect(3); + + const user11 = this.data['res.users'].records.find(user => user.id === 11); + this.data['res.users'].fields.partner_ids = { string: "Partner", type: "many2many", relation: 'res.users' }; + user11.partner_ids = []; + for (let i = 100; i < 200; i++) { + this.data['res.users'].records.push({ id: i, display_name: `partner${i}` }); + user11.partner_ids.push(i); + } + + const { widget: form } = await start({ + hasView: true, + View: FormView, + model: 'res.users', + data: this.data, + arch: '<form><field name="partner_ids" widget="many2many_tags"/></form>', + res_id: 11, + }); + + assert.strictEqual(form.$('.o_field_widget[name="partner_ids"] .badge').length, 100); + + await testUtils.form.clickEdit(form); + + assert.hasClass(form.$('.o_form_view'), 'o_form_editable'); + + // add a record to the relation + await testUtils.fields.many2one.clickOpenDropdown('partner_ids'); + await testUtils.fields.many2one.clickHighlightedItem('partner_ids'); + + assert.strictEqual(form.$('.o_field_widget[name="partner_ids"] .badge').length, 101); + + form.destroy(); +}); + +}); + +}); diff --git a/addons/mail/static/tests/document_viewer_tests.js b/addons/mail/static/tests/document_viewer_tests.js new file mode 100644 index 00000000..534e0fa4 --- /dev/null +++ b/addons/mail/static/tests/document_viewer_tests.js @@ -0,0 +1,232 @@ +odoo.define('mail.document_viewer_tests', function (require) { +"use strict"; + +var DocumentViewer = require('mail.DocumentViewer'); + +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +/** + * @param {Object} params + * @param {Object[]} params.attachments + * @param {int} params.attachmentID + * @param {function} [params.mockRPC] + * @param {boolean} [params.debug] + * @returns {DocumentViewer} + */ +var createViewer = async function (params) { + var parent = new Widget(); + var viewer = new DocumentViewer(parent, params.attachments, params.attachmentID); + + var mockRPC = function (route) { + if (route === '/web/static/lib/pdfjs/web/viewer.html?file=/web/content/1?model%3Dir.attachment%26filename%3DfilePdf.pdf') { + return Promise.resolve(); + } + if (route === 'https://www.youtube.com/embed/FYqW0Gdwbzk') { + return Promise.resolve(); + } + if (route === '/web/content/4?model=ir.attachment') { + return Promise.resolve(); + } + if (route === '/web/image/6?unique=56789abc&model=ir.attachment') { + return Promise.resolve(); + } + }; + await testUtils.mock.addMockEnvironment(parent, { + mockRPC: function () { + if (params.mockRPC) { + var _super = this._super; + this._super = mockRPC; + var def = params.mockRPC.apply(this, arguments); + this._super = _super; + return def; + } else { + return mockRPC.apply(this, arguments); + } + }, + intercepts: params.intercepts || {}, + }); + var $target = $("#qunit-fixture"); + if (params.debug) { + $target = $('body'); + $target.addClass('debug'); + } + + // actually destroy the parent when the viewer is destroyed + viewer.destroy = function () { + delete viewer.destroy; + parent.destroy(); + }; + return viewer.appendTo($target).then(function() { + return viewer; + }); +}; + +QUnit.module('mail', {}, function () { +QUnit.module('document_viewer_tests.js', { + beforeEach: function () { + this.attachments = [ + {id: 1, name: 'filePdf.pdf', type: 'binary', mimetype: 'application/pdf', datas:'R0lGOP////ywAADs='}, + {id: 2, name: 'urlYoutube', type: 'url', mimetype: '', url: 'https://youtu.be/FYqW0Gdwbzk'}, + {id: 3, name: 'urlRandom', type: 'url', mimetype: '', url: 'https://www.google.com'}, + {id: 4, name: 'text.html', type: 'binary', mimetype: 'text/html', datas:'testee'}, + {id: 5, name: 'video.mp4', type: 'binary', mimetype: 'video/mp4', datas:'R0lDOP////ywAADs='}, + {id: 6, name: 'image.jpg', type: 'binary', mimetype: 'image/jpeg', checksum: '123456789abc', datas:'R0lVOP////ywAADs='}, + ]; + }, +}, function () { + + QUnit.test('basic rendering', async function (assert) { + assert.expect(7); + + var viewer = await createViewer({ + attachmentID: 1, + attachments: this.attachments, + }); + + assert.containsOnce(viewer, '.o_viewer_content', + "there should be a preview"); + assert.containsOnce(viewer, '.o_close_btn', + "there should be a close button"); + assert.containsOnce(viewer, '.o_viewer-header', + "there should be a header"); + assert.containsOnce(viewer, '.o_image_caption', + "there should be an image caption"); + assert.containsOnce(viewer, '.o_viewer_zoomer', + "there should be a zoomer"); + assert.containsOnce(viewer, '.fa-chevron-right', + "there should be a right nav icon"); + assert.containsOnce(viewer, '.fa-chevron-left', + "there should be a left nav icon"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer Youtube', async function (assert) { + assert.expect(3); + + var youtubeURL = 'https://www.youtube.com/embed/FYqW0Gdwbzk'; + var viewer = await createViewer({ + attachmentID: 2, + attachments: this.attachments, + mockRPC: function (route) { + if (route === youtubeURL) { + assert.ok(true, "should have called youtube URL"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('urlYoutube')").length, 1, + "the viewer should be on the right attachment"); + assert.containsOnce(viewer, '.o_viewer_text[data-src="' + youtubeURL + '"]', + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer html/(txt)', async function (assert) { + assert.expect(2); + + var viewer = await createViewer({ + attachmentID: 4, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('text.html')").length, 1, + "the viewer be on the right attachment"); + assert.containsOnce(viewer, 'iframe[data-src="/web/content/4?model=ir.attachment"]', + "there should be an iframe with the right src"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer mp4', async function (assert) { + assert.expect(2); + + var viewer = await createViewer({ + attachmentID: 5, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('video.mp4')").length, 1, + "the viewer be on the right attachment"); + assert.containsOnce(viewer, '.o_viewer_video', + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('Document Viewer jpg', async function (assert) { + assert.expect(2); + + var viewer = await createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.strictEqual(viewer.$(".o_image_caption:contains('image.jpg')").length, 1, + "the viewer be on the right attachment"); + assert.containsOnce(viewer, 'img[data-src="/web/image/6?unique=56789abc&model=ir.attachment"]', + "there should be a video player"); + + viewer.destroy(); + }); + + QUnit.test('is closable by button', async function (assert) { + assert.expect(3); + + var viewer = await createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.containsOnce(viewer, '.o_viewer_content', + "should have a document viewer"); + assert.containsOnce(viewer, '.o_close_btn', + "should have a close button"); + + await testUtils.dom.click(viewer.$('.o_close_btn')); + + assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); + }); + + QUnit.test('is closable by clicking on the wrapper', async function (assert) { + assert.expect(3); + + var viewer = await createViewer({ + attachmentID: 6, + attachments: this.attachments, + }); + + assert.containsOnce(viewer, '.o_viewer_content', + "should have a document viewer"); + assert.containsOnce(viewer, '.o_viewer_img_wrapper', + "should have a wrapper"); + + await testUtils.dom.click(viewer.$('.o_viewer_img_wrapper')); + + assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); + }); + + QUnit.test('fileType and integrity test', async function (assert) { + assert.expect(3); + + var viewer = await createViewer({ + attachmentID: 2, + attachments: this.attachments, + }); + + assert.strictEqual(this.attachments[1].type, 'url', + "the type should be url"); + assert.strictEqual(this.attachments[1].fileType, 'youtu', + "there should be a fileType 'youtu'"); + assert.strictEqual(this.attachments[1].youtube, 'FYqW0Gdwbzk', + "there should be a youtube token"); + + viewer.destroy(); + }); +}); +}); + +}); diff --git a/addons/mail/static/tests/helpers/mock_models.js b/addons/mail/static/tests/helpers/mock_models.js new file mode 100644 index 00000000..873b5b0b --- /dev/null +++ b/addons/mail/static/tests/helpers/mock_models.js @@ -0,0 +1,258 @@ +odoo.define('mail/static/tests/helpers/mock_models.js', function (require) { +'use strict'; + +const patchMixin = require('web.patchMixin'); + +/** + * Allows to generate mocked models that will be used by the mocked server. + * This is defined as a class to allow patches by dependent modules and a new + * data object is generated every time to ensure any test can modify it without + * impacting other tests. + */ +class MockModels { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns a new data set of mocked models. + * + * @static + * @returns {Object} + */ + static generateData() { + return { + 'ir.attachment': { + fields: { + create_date: { type: 'date' }, + create_uid: { string: "Created By", type: "many2one", relation: 'res.users' }, + datas: { string: "File Content (base64)", type: 'binary' }, + mimetype: { string: "mimetype", type: 'char' }, + name: { string: "attachment name", type: 'char', required: true }, + res_id: { string: "res id", type: 'integer' }, + res_model: { type: 'char', string: "res model" }, + type: { type: 'selection', selection: [['url', "URL"], ['binary', "BINARY"]] }, + url: { string: 'url', type: 'char' }, + }, + records: [], + }, + 'mail.activity': { + fields: { + activity_category: { string: "Category", type: 'selection', selection: [['default', 'Other'], ['upload_file', 'Upload File']] }, + activity_type_id: { string: "Activity type", type: "many2one", relation: "mail.activity.type" }, + can_write: { string: "Can write", type: "boolean" }, + create_uid: { string: "Created By", type: "many2one", relation: 'res.users' }, + display_name: { string: "Display name", type: "char" }, + date_deadline: { string: "Due Date", type: "date", default() { return moment().format('YYYY-MM-DD'); } }, + icon: { type: 'char' }, + note: { string: "Note", type: "html" }, + res_id: { type: 'integer' }, + res_model: { type: 'char' }, + state: { string: 'State', type: 'selection', selection: [['overdue', 'Overdue'], ['today', 'Today'], ['planned', 'Planned']] }, + user_id: { string: "Assigned to", type: "many2one", relation: 'res.users' }, + }, + records: [], + }, + 'mail.activity.type': { + fields: { + category: { string: 'Category', type: 'selection', selection: [['default', 'Other'], ['upload_file', 'Upload File']] }, + decoration_type: { string: "Decoration Type", type: "selection", selection: [['warning', 'Alert'], ['danger', 'Error']] }, + icon: { string: 'icon', type: "char" }, + name: { string: "Name", type: "char" }, + }, + records: [ + { icon: 'fa-envelope', id: 1, name: "Email" }, + ], + }, + 'mail.channel': { + fields: { + channel_type: { string: "Channel Type", type: "selection", default: 'channel' }, + // Equivalent to members but required due to some RPC giving this field in domain. + channel_partner_ids: { string: "Channel Partner Ids", type: 'many2many', relation: 'res.partner' }, + // In python this belongs to mail.channel.partner. Here for simplicity. + custom_channel_name: { string: "Custom channel name", type: 'char' }, + fetched_message_id: { string: "Last Fetched", type: 'many2one', relation: 'mail.message' }, + group_based_subscription: { string: "Group based subscription", type: "boolean", default: false }, + id: { string: "Id", type: 'integer' }, + // In python this belongs to mail.channel.partner. Here for simplicity. + is_minimized: { string: "isMinimized", type: "boolean", default: false }, + // In python it is moderator_ids. Here for simplicity. + is_moderator: { string: "Is current partner moderator?", type: "boolean", default: false }, + // In python this belongs to mail.channel.partner. Here for simplicity. + is_pinned: { string: "isPinned", type: "boolean", default: true }, + // In python: email_send. + mass_mailing: { string: "Send messages by email", type: "boolean", default: false }, + members: { string: "Members", type: 'many2many', relation: 'res.partner', default() { return [this.currentPartnerId]; } }, + message_unread_counter: { string: "# unread messages", type: 'integer' }, + moderation: { string: "Moderation", type: 'boolean', default: false }, + name: { string: "Name", type: "char", required: true }, + public: { string: "Public", type: "boolean", default: 'groups' }, + seen_message_id: { string: "Last Seen", type: 'many2one', relation: 'mail.message' }, + // In python this belongs to mail.channel.partner. Here for simplicity. + state: { string: "FoldState", type: "char", default: 'open' }, + // naive and non RFC-compliant UUID, good enough for the + // string comparison that are done with it during tests + uuid: { string: "UUID", type: "char", required: true, default() { return _.uniqueId('mail.channel_uuid-'); } }, + }, + records: [], + }, + // Fake model to simulate "hardcoded" commands from python + 'mail.channel_command': { + fields: { + channel_types: { type: 'binary' }, // array is expected + help: { type: 'char' }, + name: { type: 'char' }, + }, + records: [], + }, + 'mail.followers': { + fields: { + channel_id: { type: 'integer' }, + email: { type: 'char' }, + id: { type: 'integer' }, + is_active: { type: 'boolean' }, + is_editable: { type: 'boolean' }, + name: { type: 'char' }, + partner_id: { type: 'integer' }, + res_id: { type: 'many2one_reference' }, + res_model: { type: 'char' }, + subtype_ids: { type: 'many2many', relation: 'mail.message.subtype' } + }, + records: [], + }, + 'mail.message': { + fields: { + attachment_ids: { string: "Attachments", type: 'many2many', relation: 'ir.attachment', default: [] }, + author_id: { string: "Author", type: 'many2one', relation: 'res.partner', default() { return this.currentPartnerId; } }, + body: { string: "Contents", type: 'html', default: "<p></p>" }, + channel_ids: { string: "Channels", type: 'many2many', relation: 'mail.channel' }, + date: { string: "Date", type: 'datetime' }, + email_from: { string: "From", type: 'char' }, + history_partner_ids: { string: "Partners with History", type: 'many2many', relation: 'res.partner' }, + id: { string: "Id", type: 'integer' }, + is_discussion: { string: "Discussion", type: 'boolean' }, + is_note: { string: "Note", type: 'boolean' }, + is_notification: { string: "Notification", type: 'boolean' }, + message_type: { string: "Type", type: 'selection', default: 'email' }, + model: { string: "Related Document model", type: 'char' }, + needaction: { string: "Need Action", type: 'boolean' }, + needaction_partner_ids: { string: "Partners with Need Action", type: 'many2many', relation: 'res.partner' }, + moderation_status: { string: "Moderation status", type: 'selection', selection: [['pending_moderation', "Pending Moderation"], ['accepted', "Accepted"], ['rejected', "Rejected"]], default: false }, + notification_ids: { string: "Notifications", type: 'one2many', relation: 'mail.notification' }, + partner_ids: { string: "Recipients", type: 'many2many', relation: 'res.partner' }, + record_name: { string: "Name", type: 'char' }, + res_id: { string: "Related Document ID", type: 'integer' }, + // In python, result of a formatter. Here for simplicity. + res_model_name: { string: "Res Model Name", type: 'char' }, + starred_partner_ids: { string: "Favorited By", type: 'many2many', relation: 'res.partner' }, + subject: { string: "Subject", type: 'char' }, + subtype_id: { string: "Subtype id", type: 'many2one', relation: 'mail.message.subtype' }, + tracking_value_ids: { relation: 'mail.tracking.value', string: "Tracking values", type: 'one2many' }, + }, + records: [], + }, + 'mail.message.subtype': { + fields: { + default: { type: 'boolean', default: true }, + description: { type: 'text' }, + hidden: { type: 'boolean' }, + internal: { type: 'boolean' }, + name: { type: 'char' }, + parent_id: { type: 'many2one', relation: 'mail.message.subtype' }, + relation_field: { type: 'char' }, + res_model: { type: 'char' }, + sequence: { type: 'integer', default: 1 }, + // not a field in Python but xml id of data + subtype_xmlid: { type: 'char' }, + }, + records: [ + { name: "Discussions", sequence: 0, subtype_xmlid: 'mail.mt_comment' }, + { default: false, internal: true, name: "Note", sequence: 100, subtype_xmlid: 'mail.mt_note' }, + { default: false, internal: true, name: "Activities", sequence: 90, subtype_xmlid: 'mail.mt_activities' }, + ], + }, + 'mail.notification': { + fields: { + failure_type: { string: "Failure Type", type: 'selection', selection: [["SMTP", "Connection failed (outgoing mail server problem)"], ["RECIPIENT", "Invalid email address"], ["BOUNCE", "Email address rejected by destination"], ["UNKNOWN", "Unknown error"]] }, + is_read: { string: "Is Read", type: 'boolean', default: false }, + mail_message_id: { string: "Message", type: 'many2one', relation: 'mail.message' }, + notification_status: { string: "Notification Status", type: 'selection', selection: [['ready', 'Ready to Send'], ['sent', 'Sent'], ['bounce', 'Bounced'], ['exception', 'Exception'], ['canceled', 'Canceled']], default: 'ready' }, + notification_type: { string: "Notification Type", type: 'selection', selection: [['email', 'Handle by Emails'], ['inbox', 'Handle in Odoo']], default: 'email' }, + res_partner_id: { string: "Needaction Recipient", type: 'many2one', relation: 'res.partner' }, + }, + records: [], + }, + 'mail.shortcode': { + fields: { + source: { type: 'char' }, + substitution: { type: 'char' }, + }, + records: [], + }, + 'mail.tracking.value': { + fields: { + changed_field: { string: 'Changed field', type: 'char' }, + field_type: { string: 'Field type', type: 'char' }, + new_value: { string: 'New value', type: 'char' }, + old_value: { string: 'Old value', type: 'char' }, + }, + records: [], + }, + 'res.country': { + fields: { + code: { string: "Code", type: 'char' }, + name: { string: "Name", type: 'char' }, + }, + records: [], + }, + 'res.partner': { + fields: { + active: { string: "Active", type: 'boolean', default: true }, + activity_ids: { string: "Activities", type: 'one2many', relation: 'mail.activity' }, + contact_address_complete: { string: "Address", type: 'char' }, + country_id: { string: "Country", type: 'many2one', relation: 'res.country' }, + description: { string: 'description', type: 'text' }, + display_name: { string: "Displayed name", type: "char" }, + email: { type: 'char' }, + image_128: { string: "Image 128", type: 'image' }, + im_status: { string: "IM Status", type: 'char' }, + message_follower_ids: { relation: 'mail.followers', string: "Followers", type: "one2many" }, + message_attachment_count: { string: 'Attachment count', type: 'integer' }, + message_ids: { string: "Messages", type: 'one2many', relation: 'mail.message' }, + name: { string: "Name", type: 'char' }, + partner_latitude: { string: "Latitude", type: 'float' }, + partner_longitude: { string: "Longitude", type: 'float' }, + }, + records: [], + }, + 'res.users': { + fields: { + active: { string: "Active", type: 'boolean', default: true }, + display_name: { string: "Display name", type: "char" }, + im_status: { string: "IM Status", type: 'char' }, + name: { string: "Name", type: 'char' }, + partner_id: { string: "Related partners", type: 'many2one', relation: 'res.partner' }, + }, + records: [], + }, + 'res.fake': { + fields: { + activity_ids: { string: "Activities", type: 'one2many', relation: 'mail.activity' }, + email_cc: { type: 'char' }, + partner_ids: { + string: "Related partners", + type: 'many2one', + relation: 'res.partner' + }, + }, + records: [], + }, + }; + } + +} + +return patchMixin(MockModels); + +}); diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js new file mode 100644 index 00000000..3574a57c --- /dev/null +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -0,0 +1,1809 @@ +odoo.define('mail.MockServer', function (require) { +"use strict"; + +const { nextAnimationFrame } = require('mail/static/src/utils/test_utils.js'); + +const MockServer = require('web.MockServer'); + +MockServer.include({ + /** + * Param 'data' may have keys for the different magic partners/users. + * + * Note: we must delete these keys, so that this is not + * handled as a model definition. + * + * @override + * @param {Object} [data.currentPartnerId] + * @param {Object} [data.currentUserId] + * @param {Object} [data.partnerRootId] + * @param {Object} [data.publicPartnerId] + * @param {Object} [data.publicUserId] + * @param {Widget} [options.widget] mocked widget (use to call services) + */ + init(data, options) { + if (data && data.currentPartnerId) { + this.currentPartnerId = data.currentPartnerId; + delete data.currentPartnerId; + } + if (data && data.currentUserId) { + this.currentUserId = data.currentUserId; + delete data.currentUserId; + } + if (data && data.partnerRootId) { + this.partnerRootId = data.partnerRootId; + delete data.partnerRootId; + } + if (data && data.publicPartnerId) { + this.publicPartnerId = data.publicPartnerId; + delete data.publicPartnerId; + } + if (data && data.publicUserId) { + this.publicUserId = data.publicUserId; + delete data.publicUserId; + } + this._widget = options.widget; + + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _performFetch(resource, init) { + if (resource === '/web/binary/upload_attachment') { + const formData = init.body; + const model = formData.get('model'); + const id = parseInt(formData.get('id')); + const ufiles = formData.getAll('ufile'); + const callback = formData.get('callback'); + + const attachmentIds = []; + for (const ufile of ufiles) { + const attachmentId = this._mockCreate('ir.attachment', { + // datas, + mimetype: ufile.type, + name: ufile.name, + res_id: id, + res_model: model, + }); + attachmentIds.push(attachmentId); + } + const attachments = this._getRecords('ir.attachment', [['id', 'in', attachmentIds]]); + const formattedAttachments = attachments.map(attachment => { + return { + 'filename': attachment.name, + 'id': attachment.id, + 'mimetype': attachment.mimetype, + 'size': attachment.file_size + }; + }); + return { + text() { + return ` + <script language="javascript" type="text/javascript"> + var win = window.top.window; + win.jQuery(win).trigger('${callback}', ${JSON.stringify(formattedAttachments)}); + </script> + `; + }, + }; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _performRpc(route, args) { + // routes + if (route === '/mail/chat_post') { + const uuid = args.uuid; + const message_content = args.message_content; + const context = args.context; + return this._mockRouteMailChatPost(uuid, message_content, context); + } + if (route === '/mail/get_suggested_recipients') { + const model = args.model; + const res_ids = args.res_ids; + return this._mockRouteMailGetSuggestedRecipient(model, res_ids); + } + if (route === '/mail/init_messaging') { + return this._mockRouteMailInitMessaging(); + } + if (route === '/mail/read_followers') { + return this._mockRouteMailReadFollowers(args); + } + if (route === '/mail/read_subscription_data') { + const follower_id = args.follower_id; + return this._mockRouteMailReadSubscriptionData(follower_id); + } + // mail.activity methods + if (args.model === 'mail.activity' && args.method === 'activity_format') { + let res = this._mockRead(args.model, args.args, args.kwargs); + res = res.map(function (record) { + if (record.mail_template_ids) { + record.mail_template_ids = record.mail_template_ids.map(function (template_id) { + return { id: template_id, name: "template" + template_id }; + }); + } + return record; + }); + return res; + } + if (args.model === 'mail.activity' && args.method === 'get_activity_data') { + const res_model = args.args[0] || args.kwargs.res_model; + const domain = args.args[1] || args.kwargs.domain; + return this._mockMailActivityGetActivityData(res_model, domain); + } + // mail.channel methods + if (args.model === 'mail.channel' && args.method === 'channel_fetched') { + const ids = args.args[0]; + return this._mockMailChannelChannelFetched(ids); + } + if (args.model === 'mail.channel' && args.method === 'channel_fetch_listeners') { + return []; + } + if (args.model === 'mail.channel' && args.method === 'channel_fetch_preview') { + const ids = args.args[0]; + return this._mockMailChannelChannelFetchPreview(ids); + } + if (args.model === 'mail.channel' && args.method === 'channel_fold') { + const uuid = args.args[0] || args.kwargs.uuid; + const state = args.args[1] || args.kwargs.state; + return this._mockMailChannelChannelFold(uuid, state); + } + if (args.model === 'mail.channel' && args.method === 'channel_get') { + const partners_to = args.args[0] || args.kwargs.partners_to; + const pin = args.args[1] !== undefined + ? args.args[1] + : args.kwargs.pin !== undefined + ? args.kwargs.pin + : undefined; + return this._mockMailChannelChannelGet(partners_to, pin); + } + if (args.model === 'mail.channel' && args.method === 'channel_info') { + const ids = args.args[0]; + return this._mockMailChannelChannelInfo(ids); + } + if (args.model === 'mail.channel' && args.method === 'channel_join_and_get_info') { + const ids = args.args[0]; + return this._mockMailChannelChannelJoinAndGetInfo(ids); + } + if (args.model === 'mail.channel' && args.method === 'channel_minimize') { + return; + } + if (args.model === 'mail.channel' && args.method === 'channel_seen') { + const channel_ids = args.args[0]; + const last_message_id = args.args[1] || args.kwargs.last_message_id; + return this._mockMailChannelChannelSeen(channel_ids, last_message_id); + } + if (args.model === 'mail.channel' && args.method === 'channel_set_custom_name') { + const channel_id = args.args[0] || args.kwargs.channel_id; + const name = args.args[1] || args.kwargs.name; + return this._mockMailChannelChannelSetCustomName(channel_id, name); + } + if (args.model === 'mail.channel' && args.method === 'execute_command') { + return this._mockMailChannelExecuteCommand(args); + } + if (args.model === 'mail.channel' && args.method === 'message_post') { + const id = args.args[0]; + const kwargs = args.kwargs; + const context = kwargs.context; + delete kwargs.context; + return this._mockMailChannelMessagePost(id, kwargs, context); + } + if (args.model === 'mail.channel' && args.method === 'notify_typing') { + const ids = args.args[0]; + const is_typing = args.args[1] || args.kwargs.is_typing; + const context = args.kwargs.context; + return this._mockMailChannelNotifyTyping(ids, is_typing, context); + } + // mail.message methods + if (args.model === 'mail.message' && args.method === 'mark_all_as_read') { + const domain = args.args[0] || args.kwargs.domain; + return this._mockMailMessageMarkAllAsRead(domain); + } + if (args.model === 'mail.message' && args.method === 'message_fetch') { + // TODO FIXME delay RPC until next potential render as a workaround + // to issue https://github.com/odoo/owl/pull/724 + await nextAnimationFrame(); + const domain = args.args[0] || args.kwargs.domain; + const limit = args.args[1] || args.kwargs.limit; + const moderated_channel_ids = args.args[2] || args.kwargs.moderated_channel_ids; + return this._mockMailMessageMessageFetch(domain, limit, moderated_channel_ids); + } + if (args.model === 'mail.message' && args.method === 'message_format') { + const ids = args.args[0]; + return this._mockMailMessageMessageFormat(ids); + } + if (args.model === 'mail.message' && args.method === 'moderate') { + return this._mockMailMessageModerate(args); + } + if (args.model === 'mail.message' && args.method === 'set_message_done') { + const ids = args.args[0]; + return this._mockMailMessageSetMessageDone(ids); + } + if (args.model === 'mail.message' && args.method === 'toggle_message_starred') { + const ids = args.args[0]; + return this._mockMailMessageToggleMessageStarred(ids); + } + if (args.model === 'mail.message' && args.method === 'unstar_all') { + return this._mockMailMessageUnstarAll(); + } + // res.partner methods + if (args.method === 'get_mention_suggestions') { + if (args.model === 'mail.channel') { + return this._mockMailChannelGetMentionSuggestions(args); + } + if (args.model === 'res.partner') { + return this._mockResPartnerGetMentionSuggestions(args); + } + } + if (args.model === 'res.partner' && args.method === 'im_search') { + const name = args.args[0] || args.kwargs.search; + const limit = args.args[1] || args.kwargs.limit; + return this._mockResPartnerImSearch(name, limit); + } + // mail.thread methods (can work on any model) + if (args.method === 'message_subscribe') { + const ids = args.args[0]; + const partner_ids = args.args[1] || args.kwargs.partner_ids; + const channel_ids = args.args[2] || args.kwargs.channel_ids; + const subtype_ids = args.args[3] || args.kwargs.subtype_ids; + return this._mockMailThreadMessageSubscribe(args.model, ids, partner_ids, channel_ids, subtype_ids); + } + if (args.method === 'message_unsubscribe') { + const ids = args.args[0]; + const partner_ids = args.args[1] || args.kwargs.partner_ids; + const channel_ids = args.args[2] || args.kwargs.channel_ids; + return this._mockMailThreadMessageUnsubscribe(args.model, ids, partner_ids, channel_ids); + } + if (args.method === 'message_post') { + const id = args.args[0]; + const kwargs = args.kwargs; + const context = kwargs.context; + delete kwargs.context; + return this._mockMailThreadMessagePost(args.model, [id], kwargs, context); + } + return this._super(route, args); + }, + + //-------------------------------------------------------------------------- + // Private Mocked Routes + //-------------------------------------------------------------------------- + + /** + * Simulates the `/mail/chat_post` route. + * + * @private + * @param {string} uuid + * @param {string} message_content + * @param {Object} [context={}] + * @returns {Object} one key for list of followers and one for subtypes + */ + async _mockRouteMailChatPost(uuid, message_content, context = {}) { + const mailChannel = this._getRecords('mail.channel', [['uuid', '=', uuid]])[0]; + if (!mailChannel) { + return false; + } + + let user_id; + // find the author from the user session + if ('mockedUserId' in context) { + // can be falsy to simulate not being logged in + user_id = context.mockedUserId; + } else { + user_id = this.currentUserId; + } + let author_id; + let email_from; + if (user_id) { + const author = this._getRecords('res.users', [['id', '=', user_id]])[0]; + author_id = author.partner_id; + email_from = `${author.display_name} <${author.email}>`; + } else { + author_id = false; + // simpler fallback than catchall_formatted + email_from = mailChannel.anonymous_name || "catchall@example.com"; + } + // supposedly should convert plain text to html + const body = message_content; + // ideally should be posted with mail_create_nosubscribe=True + return this._mockMailChannelMessagePost( + mailChannel.id, + { + author_id, + email_from, + body, + message_type: 'comment', + subtype_xmlid: 'mail.mt_comment', + }, + context + ); + }, + /** + * Simulates `/mail/get_suggested_recipients` route. + * + * @private + * @returns {string} model + * @returns {integer[]} res_ids + * @returns {Object} + */ + _mockRouteMailGetSuggestedRecipient(model, res_ids) { + if (model === 'res.fake') { + return this._mockResFake_MessageGetSuggestedRecipients(model, res_ids); + } + return this._mockMailThread_MessageGetSuggestedRecipients(model, res_ids); + }, + /** + * Simulates the `/mail/init_messaging` route. + * + * @private + * @returns {Object} + */ + _mockRouteMailInitMessaging() { + const channels = this._getRecords('mail.channel', [ + ['channel_type', '=', 'channel'], + ['members', 'in', this.currentPartnerId], + ['public', 'in', ['public', 'groups']], + ]); + const channelInfos = this._mockMailChannelChannelInfo(channels.map(channel => channel.id)); + + const directMessages = this._getRecords('mail.channel', [ + ['channel_type', '=', 'chat'], + ['is_pinned', '=', true], + ['members', 'in', this.currentPartnerId], + ]); + const directMessageInfos = this._mockMailChannelChannelInfo(directMessages.map(channel => channel.id)); + + const privateGroups = this._getRecords('mail.channel', [ + ['channel_type', '=', 'channel'], + ['members', 'in', this.currentPartnerId], + ['public', '=', 'private'], + ]); + const privateGroupInfos = this._mockMailChannelChannelInfo(privateGroups.map(channel => channel.id)); + + const moderation_channel_ids = this._getRecords('mail.channel', [['is_moderator', '=', true]]).map(channel => channel.id); + const moderation_counter = this._getRecords('mail.message', [ + ['model', '=', 'mail.channel'], + ['res_id', 'in', moderation_channel_ids], + ['moderation_status', '=', 'pending_moderation'], + ]).length; + + const partnerRoot = this._getRecords( + 'res.partner', + [['id', '=', this.partnerRootId]], + { active_test: false } + )[0]; + const partnerRootFormat = this._mockResPartnerMailPartnerFormat(partnerRoot.id); + + const publicPartner = this._getRecords( + 'res.partner', + [['id', '=', this.publicPartnerId]], + { active_test: false } + )[0]; + const publicPartnerFormat = this._mockResPartnerMailPartnerFormat(publicPartner.id); + + const currentPartner = this._getRecords('res.partner', [['id', '=', this.currentPartnerId]])[0]; + const currentPartnerFormat = this._mockResPartnerMailPartnerFormat(currentPartner.id); + + const needaction_inbox_counter = this._mockResPartnerGetNeedactionCount(); + + const mailFailures = this._mockMailMessageMessageFetchFailed(); + + const shortcodes = this._getRecords('mail.shortcode', []); + + const commands = this._getRecords('mail.channel_command', []); + + const starredCounter = this._getRecords('mail.message', [ + ['starred_partner_ids', 'in', this.currentPartnerId], + ]).length; + + return { + channel_slots: { + channel_channel: channelInfos, + channel_direct_message: directMessageInfos, + channel_private_group: privateGroupInfos, + }, + commands, + current_partner: currentPartnerFormat, + current_user_id: this.currentUserId, + mail_failures: mailFailures, + mention_partner_suggestions: [], + menu_id: false, + moderation_channel_ids, + moderation_counter, + needaction_inbox_counter, + partner_root: partnerRootFormat, + public_partner: publicPartnerFormat, + shortcodes, + starred_counter: starredCounter, + }; + }, + /** + * Simulates the `/mail/read_followers` route. + * + * @private + * @param {integer[]} follower_ids + * @returns {Object} one key for list of followers and one for subtypes + */ + async _mockRouteMailReadFollowers(args) { + const res_id = args.res_id; // id of record to read the followers + const res_model = args.res_model; // model of record to read the followers + const followers = this._getRecords('mail.followers', [['res_id', '=', res_id], ['res_model', '=', res_model]]); + const currentPartnerFollower = followers.find(follower => follower.id === this.currentPartnerId); + const subtypes = currentPartnerFollower + ? this._mockRouteMailReadSubscriptionData(currentPartnerFollower.id) + : false; + return { followers, subtypes }; + }, + /** + * Simulates the `/mail/read_subscription_data` route. + * + * @private + * @param {integer} follower_id + * @returns {Object[]} list of followed subtypes + */ + async _mockRouteMailReadSubscriptionData(follower_id) { + const follower = this._getRecords('mail.followers', [['id', '=', follower_id]])[0]; + const subtypes = this._getRecords('mail.message.subtype', [ + '&', + ['hidden', '=', false], + '|', + ['res_model', '=', follower.res_model], + ['res_model', '=', false], + ]); + const subtypes_list = subtypes.map(subtype => { + const parent = this._getRecords('mail.message.subtype', [ + ['id', '=', subtype.parent_id], + ])[0]; + return { + 'default': subtype.default, + 'followed': follower.subtype_ids.includes(subtype.id), + 'id': subtype.id, + 'internal': subtype.internal, + 'name': subtype.name, + 'parent_model': parent ? parent.res_model : false, + 'res_model': subtype.res_model, + 'sequence': subtype.sequence, + }; + }); + // NOTE: server is also doing a sort here, not reproduced for simplicity + return subtypes_list; + }, + + //-------------------------------------------------------------------------- + // Private Mocked Methods + //-------------------------------------------------------------------------- + + /** + * Simulates `get_activity_data` on `mail.activity`. + * + * @private + * @param {string} res_model + * @param {string} domain + * @returns {Object} + */ + _mockMailActivityGetActivityData(res_model, domain) { + const self = this; + const records = this._getRecords(res_model, domain); + + const activityTypes = this._getRecords('mail.activity.type', []); + const activityIds = _.pluck(records, 'activity_ids').flat(); + + const groupedActivities = {}; + const resIdToDeadline = {}; + const groups = self._mockReadGroup('mail.activity', { + domain: [['id', 'in', activityIds]], + fields: ['res_id', 'activity_type_id', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)'], + groupby: ['res_id', 'activity_type_id'], + lazy: false, + }); + groups.forEach(function (group) { + // mockReadGroup doesn't correctly return all asked fields + const activites = self._getRecords('mail.activity', group.__domain); + group.activity_type_id = group.activity_type_id[0]; + let minDate; + activites.forEach(function (activity) { + if (!minDate || moment(activity.date_deadline) < moment(minDate)) { + minDate = activity.date_deadline; + } + }); + group.date_deadline = minDate; + resIdToDeadline[group.res_id] = minDate; + let state; + if (group.date_deadline === moment().format("YYYY-MM-DD")) { + state = 'today'; + } else if (moment(group.date_deadline) > moment()) { + state = 'planned'; + } else { + state = 'overdue'; + } + if (!groupedActivities[group.res_id]) { + groupedActivities[group.res_id] = {}; + } + groupedActivities[group.res_id][group.activity_type_id] = { + count: group.__count, + state: state, + o_closest_deadline: group.date_deadline, + ids: _.pluck(activites, 'id'), + }; + }); + + return { + activity_types: activityTypes.map(function (type) { + let mailTemplates = []; + if (type.mail_template_ids) { + mailTemplates = type.mail_template_ids.map(function (id) { + const template = _.findWhere(self.data['mail.template'].records, { id: id }); + return { + id: id, + name: template.name, + }; + }); + } + return [type.id, type.display_name, mailTemplates]; + }), + activity_res_ids: _.sortBy(_.pluck(records, 'id'), function (id) { + return moment(resIdToDeadline[id]); + }), + grouped_activities: groupedActivities, + }; + }, + /** + * Simulates `_broadcast` on `mail.channel`. + * + * @private + * @param {integer} id + * @param {integer[]} partner_ids + * @returns {Object} + */ + _mockMailChannel_broadcast(ids, partner_ids) { + const notifications = this._mockMailChannel_channelChannelNotifications(ids, partner_ids); + this._widget.call('bus_service', 'trigger', 'notification', notifications); + }, + /** + * Simulates `_channel_channel_notifications` on `mail.channel`. + * + * @private + * @param {integer} id + * @param {integer[]} partner_ids + * @returns {Object} + */ + _mockMailChannel_channelChannelNotifications(ids, partner_ids) { + const notifications = []; + for (const partner_id of partner_ids) { + const user = this._getRecords('res.users', [['partner_id', 'in', partner_id]])[0]; + if (!user) { + continue; + } + // Note: `channel_info` on the server is supposed to be called with + // the proper user context, but this is not done here for simplicity + // of not having `channel.partner`. + const channelInfos = this._mockMailChannelChannelInfo(ids); + for (const channelInfo of channelInfos) { + notifications.push([[false, 'res.partner', partner_id], channelInfo]); + } + } + return notifications; + }, + /** + * Simulates `channel_fetched` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @param {string} extra_info + */ + _mockMailChannelChannelFetched(ids) { + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + for (const channel of channels) { + const channelMessages = this._getRecords('mail.message', [['channel_ids', 'in', channel.id]]); + const lastMessage = channelMessages.reduce((lastMessage, message) => { + if (message.id > lastMessage.id) { + return message; + } + return lastMessage; + }, channelMessages[0]); + if (!lastMessage) { + continue; + } + this._mockWrite('mail.channel', [ + [channel.id], + { fetched_message_id: lastMessage.id }, + ]); + const notification = [ + ["dbName", 'mail.channel', channel.id], + { + id: `${channel.id}/${this.currentPartnerId}`, // simulate channel.partner id + info: 'channel_fetched', + last_message_id: lastMessage.id, + partner_id: this.currentPartnerId, + }, + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + } + }, + /** + * Simulates `channel_fetch_preview` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @returns {Object[]} list of channels previews + */ + _mockMailChannelChannelFetchPreview(ids) { + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + return channels.map(channel => { + const channelMessages = this._getRecords('mail.message', [['channel_ids', 'in', channel.id]]); + const lastMessage = channelMessages.reduce((lastMessage, message) => { + if (message.id > lastMessage.id) { + return message; + } + return lastMessage; + }, channelMessages[0]); + return { + id: channel.id, + last_message: lastMessage ? this._mockMailMessageMessageFormat([lastMessage.id])[0] : false, + }; + }); + }, + /** + * Simulates the 'channel_fold' route on `mail.channel`. + * In particular sends a notification on the bus. + * + * @private + * @param {string} uuid + * @param {state} [state] + */ + _mockMailChannelChannelFold(uuid, state) { + const channel = this._getRecords('mail.channel', [['uuid', '=', uuid]])[0]; + this._mockWrite('mail.channel', [ + [channel.id], + { + is_minimized: state !== 'closed', + state, + } + ]); + const notifConfirmFold = [ + ["dbName", 'res.partner', this.currentPartnerId], + this._mockMailChannelChannelInfo([channel.id])[0] + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notifConfirmFold]); + }, + /** + * Simulates 'channel_get' on 'mail.channel'. + * + * @private + * @param {integer[]} [partners_to=[]] + * @param {boolean} [pin=true] + * @returns {Object} + */ + _mockMailChannelChannelGet(partners_to = [], pin = true) { + if (partners_to.length === 0) { + return false; + } + if (!partners_to.includes(this.currentPartnerId)) { + partners_to.push(this.currentPartnerId); + } + const partners = this._getRecords('res.partner', [['id', 'in', partners_to]]); + + // NOTE: this mock is not complete, which is done for simplicity. + // Indeed if a chat already exists for the given partners, the server + // is supposed to return this existing chat. But the mock is currently + // always creating a new chat, because no test is relying on receiving + // an existing chat. + const id = this._mockCreate('mail.channel', { + channel_type: 'chat', + mass_mailing: false, + is_minimized: true, + is_pinned: true, + members: [[6, 0, partners_to]], + name: partners.map(partner => partner.name).join(", "), + public: 'private', + state: 'open', + }); + return this._mockMailChannelChannelInfo([id])[0]; + }, + /** + * Simulates `channel_info` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @param {string} [extra_info] + * @returns {Object[]} + */ + _mockMailChannelChannelInfo(ids, extra_info) { + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + const all_partners = [...new Set(channels.reduce((all_partners, channel) => { + return [...all_partners, ...channel.members]; + }, []))]; + const direct_partners = [...new Set(channels.reduce((all_partners, channel) => { + if (channel.channel_type === 'chat') { + return [...all_partners, ...channel.members]; + } + return all_partners; + }, []))]; + const partnerInfos = this._mockMailChannelPartnerInfo(all_partners, direct_partners); + return channels.map(channel => { + const members = channel.members.map(partnerId => partnerInfos[partnerId]); + const messages = this._getRecords('mail.message', [ + ['channel_ids', 'in', [channel.id]], + ]); + const lastMessageId = messages.reduce((lastMessageId, message) => { + if (!lastMessageId || message.id > lastMessageId) { + return message.id; + } + return lastMessageId; + }, undefined); + const messageNeedactionCounter = this._getRecords('mail.notification', [ + ['res_partner_id', '=', this.currentPartnerId], + ['is_read', '=', false], + ['mail_message_id', 'in', messages.map(message => message.id)], + ]).length; + const res = Object.assign({}, channel, { + info: extra_info, + last_message_id: lastMessageId, + members, + message_needaction_counter: messageNeedactionCounter, + }); + if (channel.channel_type === 'channel') { + delete res.members; + } + return res; + }); + }, + /** + * Simulates `channel_join_and_get_info` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @returns {Object[]} + */ + _mockMailChannelChannelJoinAndGetInfo(ids) { + const id = ids[0]; // ensure one + const channel = this._getRecords('mail.channel', [['id', '=', id]])[0]; + // channel.partner not handled here for simplicity + if (!channel.is_pinned) { + this._mockWrite('mail.channel', [ + [channel.id], + { is_pinned: true }, + ]); + const body = `<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="${channel.id}">#${channel.name}</a></div>`; + const message_type = "notification"; + const subtype_xmlid = "mail.mt_comment"; + this._mockMailChannelMessagePost( + 'mail.channel', + [channel.id], + { body, message_type, subtype_xmlid }, + ); + } + // moderation_guidelines not handled here for simplicity + const channelInfo = this._mockMailChannelChannelInfo([channel.id], 'join')[0]; + const notification = [[false, 'res.partner', this.currentPartnerId], channelInfo]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + return channelInfo; + }, + /** + * Simulates the `channel_seen` method of `mail.channel`. + * + * @private + * @param integer[] ids + * @param {integer} last_message_id + */ + async _mockMailChannelChannelSeen(ids, last_message_id) { + // Update record + const channel_id = ids[0]; + if (!channel_id) { + throw new Error('Should only be one channel in channel_seen mock params'); + } + const channel = this._getRecords('mail.channel', [['id', '=', channel_id]])[0]; + const messagesBeforeGivenLastMessage = this._getRecords('mail.message', [ + ['channel_ids', 'in', [channel.id]], + ['id', '<=', last_message_id], + ]); + if (!messagesBeforeGivenLastMessage || messagesBeforeGivenLastMessage.length === 0) { + return; + } + if (!channel) { + return; + } + if (channel.seen_message_id && channel.seen_message_id >= last_message_id) { + return; + } + this._mockMailChannel_SetLastSeenMessage([channel.id], last_message_id); + + // Send notification + const payload = { + channel_id, + info: 'channel_seen', + last_message_id, + partner_id: this.currentPartnerId, + }; + let notification; + if (channel.channel_type === 'chat') { + notification = [[false, 'mail.channel', channel_id], payload]; + } else { + notification = [[false, 'res.partner', this.currentPartnerId], payload]; + } + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + }, + /** + * Simulates `channel_set_custom_name` on `mail.channel`. + * + * @private + * @param {integer} channel_id + * @returns {string} [name] + */ + _mockMailChannelChannelSetCustomName(channel_id, name) { + this._mockWrite('mail.channel', [ + [channel_id], + { custom_channel_name: name }, + ]); + }, + /** + * Simulates `execute_command` on `mail.channel`. + * In particular sends a notification on the bus. + * + * @private + */ + _mockMailChannelExecuteCommand(args) { + const ids = args.args[0]; + const commandName = args.kwargs.command || args.args[1]; + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + if (commandName === 'leave') { + for (const channel of channels) { + this._mockWrite('mail.channel', [ + [channel.id], + { is_pinned: false }, + ]); + const notifConfirmUnpin = [ + ["dbName", 'res.partner', this.currentPartnerId], + Object.assign({}, channel, { info: 'unsubscribe' }) + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notifConfirmUnpin]); + } + return; + } else if (commandName === 'who') { + for (const channel of channels) { + const members = channel.members.map(memberId => this._getRecords('res.partner', [['id', '=', memberId]])[0].name); + let message = "You are alone in this channel."; + if (members.length > 0) { + message = `Users in this channel: ${members.join(', ')} and you`; + } + const notification = [ + ["dbName", 'res.partner', this.currentPartnerId], + { + 'body': `<span class="o_mail_notification">${message}</span>`, + 'channel_ids': [channel.id], + 'info': 'transient_message', + } + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + } + return; + } + throw new Error(`mail/mock_server: the route execute_command doesn't implement the command "${commandName}"`); + }, + /** + * Simulates `get_mention_suggestions` on `mail.channel`. + * + * @private + * @returns {Array[]} + */ + _mockMailChannelGetMentionSuggestions(args) { + const search = args.kwargs.search || ''; + const limit = args.kwargs.limit || 8; + + /** + * Returns the given list of channels after filtering it according to + * the logic of the Python method `get_mention_suggestions` for the + * given search term. The result is truncated to the given limit and + * formatted as expected by the original method. + * + * @param {Object[]} channels + * @param {string} search + * @param {integer} limit + * @returns {Object[]} + */ + const mentionSuggestionsFilter = function (channels, search, limit) { + const matchingChannels = channels + .filter(channel => { + // no search term is considered as return all + if (!search) { + return true; + } + // otherwise name or email must match search term + if (channel.name && channel.name.includes(search)) { + return true; + } + return false; + }).map(channel => { + // expected format + return { + id: channel.id, + name: channel.name, + public: channel.public, + }; + }); + // reduce results to max limit + matchingChannels.length = Math.min(matchingChannels.length, limit); + return matchingChannels; + }; + + const mentionSuggestions = mentionSuggestionsFilter(this.data['mail.channel'].records, search, limit); + + return mentionSuggestions; + }, + /** + * Simulates `message_post` on `mail.channel`. + * + * For simplicity this mock handles a simple case in regard to moderation: + * - messages from JS are assumed to be always sent by the current partner, + * - moderation white list and black list are not checked. + * + * @private + * @param {integer} id + * @param {Object} kwargs + * @param {Object} [context] + * @returns {integer|false} + */ + _mockMailChannelMessagePost(id, kwargs, context) { + const message_type = kwargs.message_type || 'notification'; + const channel = this._getRecords('mail.channel', [['id', '=', id]])[0]; + if (channel.channel_type !== 'channel' && !channel.is_pinned) { + // channel.partner not handled here for simplicity + this._mockWrite('mail.channel', [ + [channel.id], + { is_pinned: true }, + ]); + } + let moderation_status = 'accepted'; + if (channel.moderation && ['email', 'comment'].includes(message_type)) { + if (!channel.is_moderator) { + moderation_status = 'pending_moderation'; + } + } + let channel_ids = []; + if (moderation_status === 'accepted') { + channel_ids = [[4, channel.id]]; + } + const messageId = this._mockMailThreadMessagePost( + 'mail.channel', + [id], + Object.assign(kwargs, { + channel_ids, + message_type, + moderation_status, + }), + context, + ); + if (kwargs.author_id === this.currentPartnerId) { + this._mockMailChannel_SetLastSeenMessage([channel.id], messageId); + } else { + this._mockWrite('mail.channel', [ + [channel.id], + { message_unread_counter: (channel.message_unread_counter || 0) + 1 }, + ]); + } + return messageId; + }, + /** + * Simulates `notify_typing` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @param {boolean} is_typing + * @param {Object} [context={}] + */ + _mockMailChannelNotifyTyping(ids, is_typing, context = {}) { + const channels = this._getRecords('mail.channel', [['id', 'in', ids]]); + let partner_id; + if ('mockedPartnerId' in context) { + partner_id = context.mockedPartnerId; + } else { + partner_id = this.currentPartnerId; + } + const partner = this._getRecords('res.partner', [['id', '=', partner_id]]); + const data = { + 'info': 'typing_status', + 'is_typing': is_typing, + 'partner_id': partner_id, + 'partner_name': partner.name, + }; + const notifications = []; + for (const channel of channels) { + notifications.push([[false, 'mail.channel', channel.id], data]); + notifications.push([channel.uuid, data]); // notify livechat users + } + this._widget.call('bus_service', 'trigger', 'notification', notifications); + }, + /** + * Simulates `partner_info` on `mail.channel`. + * + * @private + * @param {integer[]} all_partners + * @param {integer[]} direct_partners + * @returns {Object[]} + */ + _mockMailChannelPartnerInfo(all_partners, direct_partners) { + const partners = this._getRecords( + 'res.partner', + [['id', 'in', all_partners]], + { active_test: false }, + ); + const partnerInfos = {}; + for (const partner of partners) { + const partnerInfo = { + email: partner.email, + id: partner.id, + name: partner.name, + }; + if (direct_partners.includes(partner.id)) { + partnerInfo.im_status = partner.im_status; + } + partnerInfos[partner.id] = partnerInfo; + } + return partnerInfos; + }, + /** + * Simulates the `_set_last_seen_message` method of `mail.channel`. + * + * @private + * @param {integer[]} ids + * @param {integer} message_id + */ + _mockMailChannel_SetLastSeenMessage(ids, message_id) { + this._mockWrite('mail.channel', [ids, { + fetched_message_id: message_id, + seen_message_id: message_id, + }]); + }, + /** + * Simulates `mark_all_as_read` on `mail.message`. + * + * @private + * @param {Array[]} [domain] + * @returns {integer[]} + */ + _mockMailMessageMarkAllAsRead(domain) { + const notifDomain = [ + ['res_partner_id', '=', this.currentPartnerId], + ['is_read', '=', false], + ]; + if (domain) { + const messages = this._getRecords('mail.message', domain); + const ids = messages.map(messages => messages.id); + this._mockMailMessageSetMessageDone(ids); + return ids; + } + const notifications = this._getRecords('mail.notification', notifDomain); + this._mockWrite('mail.notification', [ + notifications.map(notification => notification.id), + { is_read: true }, + ]); + const messageIds = []; + for (const notification of notifications) { + if (!messageIds.includes(notification.mail_message_id)) { + messageIds.push(notification.mail_message_id); + } + } + const messages = this._getRecords('mail.message', [['id', 'in', messageIds]]); + // simulate compute that should be done based on notifications + for (const message of messages) { + this._mockWrite('mail.message', [ + [message.id], + { + needaction: false, + needaction_partner_ids: message.needaction_partner_ids.filter( + partnerId => partnerId !== this.currentPartnerId + ), + }, + ]); + } + const notificationData = { type: 'mark_as_read', message_ids: messageIds, needaction_inbox_counter: this._mockResPartnerGetNeedactionCount() }; + const notification = [[false, 'res.partner', this.currentPartnerId], notificationData]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + return messageIds; + }, + /** + * Simulates `message_fetch` on `mail.message`. + * + * @private + * @param {Array[]} domain + * @param {string} [limit=20] + * @param {Object} [moderated_channel_ids] + * @returns {Object[]} + */ + _mockMailMessageMessageFetch(domain, limit = 20, moderated_channel_ids) { + let messages = this._getRecords('mail.message', domain); + if (moderated_channel_ids) { + const mod_messages = this._getRecords('mail.message', [ + ['model', '=', 'mail.channel'], + ['res_id', 'in', moderated_channel_ids], + '|', + ['author_id', '=', this.currentPartnerId], + ['moderation_status', '=', 'pending_moderation'], + ]); + messages = [...new Set([...messages, ...mod_messages])]; + } + // sorted from highest ID to lowest ID (i.e. from youngest to oldest) + messages.sort(function (m1, m2) { + return m1.id < m2.id ? 1 : -1; + }); + // pick at most 'limit' messages + messages.length = Math.min(messages.length, limit); + return this._mockMailMessageMessageFormat(messages.map(message => message.id)); + }, + /** + * Simulates `message_fetch_failed` on `mail.message`. + * + * @private + * @returns {Object[]} + */ + _mockMailMessageMessageFetchFailed() { + const messages = this._getRecords('mail.message', [ + ['author_id', '=', this.currentPartnerId], + ['res_id', '!=', 0], + ['model', '!=', false], + ['message_type', '!=', 'user_notification'], + ]).filter(message => { + // Purpose is to simulate the following domain on mail.message: + // ['notification_ids.notification_status', 'in', ['bounce', 'exception']], + // But it's not supported by _getRecords domain to follow a relation. + const notifications = this._getRecords('mail.notification', [ + ['mail_message_id', '=', message.id], + ['notification_status', 'in', ['bounce', 'exception']], + ]); + return notifications.length > 0; + }); + return this._mockMailMessage_MessageNotificationFormat(messages.map(message => message.id)); + }, + /** + * Simulates `message_format` on `mail.message`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailMessageMessageFormat(ids) { + const messages = this._getRecords('mail.message', [['id', 'in', ids]]); + // sorted from highest ID to lowest ID (i.e. from most to least recent) + messages.sort(function (m1, m2) { + return m1.id < m2.id ? 1 : -1; + }); + return messages.map(message => { + const thread = message.model && this._getRecords(message.model, [ + ['id', '=', message.res_id], + ])[0]; + let formattedAuthor; + if (message.author_id) { + const author = this._getRecords( + 'res.partner', + [['id', '=', message.author_id]], + { active_test: false } + )[0]; + formattedAuthor = [author.id, author.display_name]; + } else { + formattedAuthor = [0, message.email_from]; + } + const attachments = this._getRecords('ir.attachment', [ + ['id', 'in', message.attachment_ids], + ]); + const formattedAttachments = attachments.map(attachment => { + return Object.assign({ + 'checksum': attachment.checksum, + 'id': attachment.id, + 'filename': attachment.name, + 'name': attachment.name, + 'mimetype': attachment.mimetype, + 'is_main': thread && thread.message_main_attachment_id === attachment.id, + 'res_id': attachment.res_id, + 'res_model': attachment.res_model, + }); + }); + const allNotifications = this._getRecords('mail.notification', [ + ['mail_message_id', '=', message.id], + ]); + const historyPartnerIds = allNotifications + .filter(notification => notification.is_read) + .map(notification => notification.res_partner_id); + const needactionPartnerIds = allNotifications + .filter(notification => !notification.is_read) + .map(notification => notification.res_partner_id); + let notifications = this._mockMailNotification_FilteredForWebClient( + allNotifications.map(notification => notification.id) + ); + notifications = this._mockMailNotification_NotificationFormat( + notifications.map(notification => notification.id) + ); + const trackingValueIds = this._getRecords('mail.tracking.value', [ + ['id', 'in', message.tracking_value_ids], + ]); + const response = Object.assign({}, message, { + attachment_ids: formattedAttachments, + author_id: formattedAuthor, + history_partner_ids: historyPartnerIds, + needaction_partner_ids: needactionPartnerIds, + notifications, + tracking_value_ids: trackingValueIds, + }); + if (message.subtype_id) { + const subtype = this._getRecords('mail.message.subtype', [ + ['id', '=', message.subtype_id], + ])[0]; + response.subtype_description = subtype.description; + } + return response; + }); + }, + /** + * Simulates `moderate` on `mail.message`. + * + * @private + */ + _mockMailMessageModerate(args) { + const messageIDs = args.args[0]; + const decision = args.args[1]; + const model = this.data['mail.message']; + if (decision === 'reject' || decision === 'discard') { + model.records = _.reject(model.records, function (rec) { + return _.contains(messageIDs, rec.id); + }); + // simulate notification back (deletion of rejected/discarded + // message in channel) + const dbName = undefined; // useless for tests + const notifData = { + message_ids: messageIDs, + type: "deletion", + }; + const metaData = [dbName, 'res.partner', this.currentPartnerId]; + const notification = [metaData, notifData]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + } else if (decision === 'accept') { + // simulate notification back (new accepted message in channel) + const messages = this._getRecords('mail.message', [['id', 'in', messageIDs]]); + for (const message of messages) { + this._mockWrite('mail.message', [[message.id], { + moderation_status: 'accepted', + }]); + this._mockMailThread_NotifyThread(model, message.channel_ids, message.id); + } + } + }, + /** + * Simulates `_message_notification_format` on `mail.message`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailMessage_MessageNotificationFormat(ids) { + const messages = this._getRecords('mail.message', [['id', 'in', ids]]); + return messages.map(message => { + let notifications = this._getRecords('mail.notification', [ + ['mail_message_id', '=', message.id], + ]); + notifications = this._mockMailNotification_FilteredForWebClient( + notifications.map(notification => notification.id) + ); + notifications = this._mockMailNotification_NotificationFormat( + notifications.map(notification => notification.id) + ); + return { + 'date': message.date, + 'id': message.id, + 'message_type': message.message_type, + 'model': message.model, + 'notifications': notifications, + 'res_id': message.res_id, + 'res_model_name': message.res_model_name, + }; + }); + }, + /** + * Simulates `set_message_done` on `mail.message`, which turns provided + * needaction message to non-needaction (i.e. they are marked as read from + * from the Inbox mailbox). Also notify on the longpoll bus that the + * messages have been marked as read, so that UI is updated. + * + * @private + * @param {integer[]} ids + */ + _mockMailMessageSetMessageDone(ids) { + const messages = this._getRecords('mail.message', [['id', 'in', ids]]); + + const notifications = this._getRecords('mail.notification', [ + ['res_partner_id', '=', this.currentPartnerId], + ['is_read', '=', false], + ['mail_message_id', 'in', messages.map(messages => messages.id)] + ]); + this._mockWrite('mail.notification', [ + notifications.map(notification => notification.id), + { is_read: true }, + ]); + // simulate compute that should be done based on notifications + for (const message of messages) { + this._mockWrite('mail.message', [ + [message.id], + { + needaction: false, + needaction_partner_ids: message.needaction_partner_ids.filter( + partnerId => partnerId !== this.currentPartnerId + ), + }, + ]); + // NOTE server is sending grouped notifications per channel_ids but + // this optimization is not needed here. + const data = { type: 'mark_as_read', message_ids: [message.id], channel_ids: message.channel_ids, needaction_inbox_counter: this._mockResPartnerGetNeedactionCount() }; + const busNotifications = [[[false, 'res.partner', this.currentPartnerId], data]]; + this._widget.call('bus_service', 'trigger', 'notification', busNotifications); + } + }, + /** + * Simulates `toggle_message_starred` on `mail.message`. + * + * @private + * @returns {integer[]} ids + */ + _mockMailMessageToggleMessageStarred(ids) { + const messages = this._getRecords('mail.message', [['id', 'in', ids]]); + for (const message of messages) { + const wasStared = message.starred_partner_ids.includes(this.currentPartnerId); + this._mockWrite('mail.message', [ + [message.id], + { starred_partner_ids: [[wasStared ? 3 : 4, this.currentPartnerId]] } + ]); + const notificationData = { + message_ids: [message.id], + starred: !wasStared, + type: 'toggle_star', + }; + const notifications = [[[false, 'res.partner', this.currentPartnerId], notificationData]]; + this._widget.call('bus_service', 'trigger', 'notification', notifications); + } + }, + /** + * Simulates `unstar_all` on `mail.message`. + * + * @private + */ + _mockMailMessageUnstarAll() { + const messages = this._getRecords('mail.message', [ + ['starred_partner_ids', 'in', this.currentPartnerId], + ]); + this._mockWrite('mail.message', [ + messages.map(message => message.id), + { starred_partner_ids: [[3, this.currentPartnerId]] } + ]); + const notificationData = { + message_ids: messages.map(message => message.id), + starred: false, + type: 'toggle_star', + }; + const notification = [[false, 'res.partner', this.currentPartnerId], notificationData]; + this._widget.call('bus_service', 'trigger', 'notification', [notification]); + }, + /** + * Simulates `_filtered_for_web_client` on `mail.notification`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailNotification_FilteredForWebClient(ids) { + return this._getRecords('mail.notification', [ + ['id', 'in', ids], + ['notification_type', '!=', 'inbox'], + ['notification_status', 'in', ['bounce', 'exception', 'canceled']], + // or "res_partner_id.partner_share" not done here for simplicity + ]); + }, + /** + * Simulates `_notification_format` on `mail.notification`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailNotification_NotificationFormat(ids) { + const notifications = this._getRecords('mail.notification', [['id', 'in', ids]]); + return notifications.map(notification => { + const partner = this._getRecords('res.partner', [['id', '=', notification.res_partner_id]])[0]; + return { + 'id': notification.id, + 'notification_type': notification.notification_type, + 'notification_status': notification.notification_status, + 'failure_type': notification.failure_type, + 'res_partner_id': [partner && partner.id, partner && partner.display_name], + }; + }); + }, + /** + * Simulates `_message_compute_author` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @param {Object} [context={}] + * @returns {Array} + */ + _MockMailThread_MessageComputeAuthor(model, ids, author_id, email_from, context = {}) { + if (author_id === undefined) { + // For simplicity partner is not guessed from email_from here, but + // that would be the first step on the server. + let user_id; + if ('mockedUserId' in context) { + // can be falsy to simulate not being logged in + user_id = context.mockedUserId + ? context.mockedUserId + : this.publicUserId; + } else { + user_id = this.currentUserId; + } + const user = this._getRecords( + 'res.users', + [['id', '=', user_id]], + { active_test: false }, + )[0]; + const author = this._getRecords( + 'res.partner', + [['id', '=', user.partner_id]], + { active_test: false }, + )[0]; + author_id = author.id; + email_from = `${author.display_name} <${author.email}>`; + } + + if (email_from === undefined) { + if (author_id) { + const author = this._getRecords( + 'res.partner', + [['id', '=', author_id]], + { active_test: false }, + )[0]; + email_from = `${author.display_name} <${author.email}>`; + } + } + + if (!email_from) { + throw Error("Unable to log message due to missing author email."); + } + + return [author_id, email_from]; + }, + /** + * Simulates `_message_add_suggested_recipient` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @param {Object} result + * @param {Object} [param3={}] + * @param {string} [param3.email] + * @param {integer} [param3.partner] + * @param {string} [param3.reason] + * @returns {Object} + */ + _mockMailThread_MessageAddSuggestedRecipient(model, ids, result, { email, partner, reason = '' } = {}) { + const record = this._getRecords(model, [['id', 'in', 'ids']])[0]; + // for simplicity + result[record.id].push([partner, email, reason]); + return result; + }, + /** + * Simulates `_message_get_suggested_recipients` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @returns {Object} + */ + _mockMailThread_MessageGetSuggestedRecipients(model, ids) { + const result = ids.reduce((result, id) => result[id] = [], {}); + const records = this._getRecords(model, [['id', 'in', ids]]); + for (const record in records) { + if (record.user_id) { + const user = this._getRecords('res.users', [['id', '=', record.user_id]]); + if (user.partner_id) { + const reason = this.data[model].fields['user_id'].string; + this._mockMailThread_MessageAddSuggestedRecipient(result, user.partner_id, reason); + } + } + } + return result; + }, + /** + * Simulates `_message_get_suggested_recipients` on `res.fake`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @returns {Object} + */ + _mockResFake_MessageGetSuggestedRecipients(model, ids) { + const result = {}; + const records = this._getRecords(model, [['id', 'in', ids]]); + + for (const record of records) { + result[record.id] = []; + if (record.email_cc) { + result[record.id].push([ + false, + record.email_cc, + 'CC email', + ]); + } + const partners = this._getRecords( + 'res.partner', + [['id', 'in', record.partner_ids]], + ); + if (partners.length) { + for (const partner of partners) { + result[record.id].push([ + partner.id, + partner.display_name, + 'Email partner', + ]); + } + } + } + + return result; + }, + /** + * Simulates `message_post` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @param {Object} kwargs + * @param {Object} [context] + * @returns {integer} + */ + _mockMailThreadMessagePost(model, ids, kwargs, context) { + const id = ids[0]; // ensure_one + if (kwargs.attachment_ids) { + const attachments = this._getRecords('ir.attachment', [ + ['id', 'in', kwargs.attachment_ids], + ['res_model', '=', 'mail.compose.message'], + ['res_id', '=', 0], + ]); + const attachmentIds = attachments.map(attachment => attachment.id); + this._mockWrite('ir.attachment', [ + attachmentIds, + { + res_id: id, + res_model: model, + }, + ]); + kwargs.attachment_ids = attachmentIds.map(attachmentId => [4, attachmentId]); + } + const subtype_xmlid = kwargs.subtype_xmlid || 'mail.mt_note'; + const [author_id, email_from] = this._MockMailThread_MessageComputeAuthor( + model, + ids, + kwargs.author_id, + kwargs.email_from, context, + ); + const values = Object.assign({}, kwargs, { + author_id, + email_from, + is_discussion: subtype_xmlid === 'mail.mt_comment', + is_note: subtype_xmlid === 'mail.mt_note', + model, + res_id: id, + }); + delete values.subtype_xmlid; + const messageId = this._mockCreate('mail.message', values); + this._mockMailThread_NotifyThread(model, ids, messageId); + return messageId; + }, + /** + * Simulates `message_subscribe` on `mail.thread`. + * + * @private + * @param {string} model not in server method but necessary for thread mock + * @param {integer[]} ids + * @param {integer[]} partner_ids + * @param {integer[]} channel_ids + * @param {integer[]} subtype_ids + * @returns {boolean} + */ + _mockMailThreadMessageSubscribe(model, ids, partner_ids, channel_ids, subtype_ids) { + // message_subscribe is too complex for a generic mock. + // mockRPC should be considered for a specific result. + }, + /** + * Simulates `_notify_thread` on `mail.thread`. + * Simplified version that sends notification to author and channel. + * + * @private + * @param {string} model not in server method but necessary for thread mock + * @param {integer[]} ids + * @param {integer} messageId + * @returns {boolean} + */ + _mockMailThread_NotifyThread(model, ids, messageId) { + const message = this._getRecords('mail.message', [['id', '=', messageId]])[0]; + const messageFormat = this._mockMailMessageMessageFormat([messageId])[0]; + const notifications = []; + // author + const notificationData = { + type: 'author', + message: messageFormat, + }; + if (message.author_id) { + notifications.push([[false, 'res.partner', message.author_id], notificationData]); + } + // members + const channels = this._getRecords('mail.channel', [['id', 'in', message.channel_ids]]); + for (const channel of channels) { + notifications.push([[false, 'mail.channel', channel.id], messageFormat]); + } + this._widget.call('bus_service', 'trigger', 'notification', notifications); + }, + /** + * Simulates `message_unsubscribe` on `mail.thread`. + * + * @private + * @param {string} model not in server method but necessary for thread mock + * @param {integer[]} ids + * @param {integer[]} partner_ids + * @param {integer[]} channel_ids + * @returns {boolean|undefined} + */ + _mockMailThreadMessageUnsubscribe(model, ids, partner_ids, channel_ids) { + if (!partner_ids && !channel_ids) { + return true; + } + const followers = this._getRecords('mail.followers', [ + ['res_model', '=', model], + ['res_id', 'in', ids], + '|', + ['partner_id', 'in', partner_ids || []], + ['channel_id', 'in', channel_ids || []], + ]); + this._mockUnlink(model, [followers.map(follower => follower.id)]); + }, + /** + * Simulates `get_mention_suggestions` on `res.partner`. + * + * @private + * @returns {Array[]} + */ + _mockResPartnerGetMentionSuggestions(args) { + const search = (args.args[0] || args.kwargs.search || '').toLowerCase(); + const limit = args.args[1] || args.kwargs.limit || 8; + + /** + * Returns the given list of partners after filtering it according to + * the logic of the Python method `get_mention_suggestions` for the + * given search term. The result is truncated to the given limit and + * formatted as expected by the original method. + * + * @param {Object[]} partners + * @param {string} search + * @param {integer} limit + * @returns {Object[]} + */ + const mentionSuggestionsFilter = function (partners, search, limit) { + const matchingPartners = partners + .filter(partner => { + // no search term is considered as return all + if (!search) { + return true; + } + // otherwise name or email must match search term + if (partner.name && partner.name.toLowerCase().includes(search)) { + return true; + } + if (partner.email && partner.email.toLowerCase().includes(search)) { + return true; + } + return false; + }).map(partner => { + // expected format + return { + email: partner.email, + id: partner.id, + name: partner.name, + }; + }); + // reduce results to max limit + matchingPartners.length = Math.min(matchingPartners.length, limit); + return matchingPartners; + }; + + // add main suggestions based on users + const partnersFromUsers = this._getRecords('res.users', []) + .map(user => this._getRecords('res.partner', [['id', '=', user.partner_id]])[0]) + .filter(partner => partner); + const mainMatchingPartners = mentionSuggestionsFilter(partnersFromUsers, search, limit); + + let extraMatchingPartners = []; + // if not enough results add extra suggestions based on partners + if (mainMatchingPartners.length < limit) { + const partners = this._getRecords('res.partner', [['id', 'not in', mainMatchingPartners.map(partner => partner.id)]]); + extraMatchingPartners = mentionSuggestionsFilter(partners, search, limit); + } + return [mainMatchingPartners, extraMatchingPartners]; + }, + /** + * Simulates `get_needaction_count` on `res.partner`. + * + * @private + */ + _mockResPartnerGetNeedactionCount() { + return this._getRecords('mail.notification', [ + ['res_partner_id', '=', this.currentPartnerId], + ['is_read', '=', false], + ]).length; + }, + /** + * Simulates `im_search` on `res.partner`. + * + * @private + * @param {string} [name=''] + * @param {integer} [limit=20] + * @returns {Object[]} + */ + _mockResPartnerImSearch(name = '', limit = 20) { + name = name.toLowerCase(); // simulates ILIKE + // simulates domain with relational parts (not supported by mock server) + const matchingPartners = this._getRecords('res.users', []) + .filter(user => { + const partner = this._getRecords('res.partner', [['id', '=', user.partner_id]])[0]; + // user must have a partner + if (!partner) { + return false; + } + // not current partner + if (partner.id === this.currentPartnerId) { + return false; + } + // no name is considered as return all + if (!name) { + return true; + } + if (partner.name && partner.name.toLowerCase().includes(name)) { + return true; + } + return false; + }).map(user => { + const partner = this._getRecords('res.partner', [['id', '=', user.partner_id]])[0]; + return { + id: partner.id, + im_status: user.im_status || 'offline', + name: partner.name, + user_id: user.id, + }; + }); + matchingPartners.length = Math.min(matchingPartners.length, limit); + return matchingPartners; + }, + /** + * Simulates `mail_partner_format` on `res.partner`. + * + * @private + * @returns {integer} id + * @returns {Object} + */ + _mockResPartnerMailPartnerFormat(id) { + const partner = this._getRecords( + 'res.partner', + [['id', '=', id]], + { active_test: false } + )[0]; + return { + "active": partner.active, + "display_name": partner.display_name, + "id": partner.id, + "im_status": partner.im_status, + "name": partner.name, + }; + }, +}); + +}); diff --git a/addons/mail/static/tests/mail_utils_tests.js b/addons/mail/static/tests/mail_utils_tests.js new file mode 100644 index 00000000..b330dc37 --- /dev/null +++ b/addons/mail/static/tests/mail_utils_tests.js @@ -0,0 +1,111 @@ +odoo.define('mail.mail_utils_tests', function (require) { +"use strict"; + +var utils = require('mail.utils'); + +QUnit.module('mail', {}, function () { + +QUnit.module('Mail utils'); + +QUnit.test('add_link utility function', function (assert) { + assert.expect(19); + + var testInputs = { + 'http://admin:password@example.com:8/%2020': true, + 'https://admin:password@example.com/test': true, + 'www.example.com:8/test': true, + 'https://127.0.0.5:8069': true, + 'www.127.0.0.5': false, + 'should.notmatch': false, + 'fhttps://test.example.com/test': false, + "https://www.transifex.com/odoo/odoo-11/translate/#fr/lunch?q=text%3A'La+Tartiflette'": true, + 'https://www.transifex.com/odoo/odoo-11/translate/#fr/$/119303430?q=text%3ATartiflette': true, + 'https://tenor.com/view/chỗgiặt-dog-smile-gif-13860250': true, + 'http://www.boîtenoire.be': true, + }; + + _.each(testInputs, function (willLinkify, content) { + var output = utils.parseAndTransform(content, utils.addLink); + if (willLinkify) { + assert.strictEqual(output.indexOf('<a '), 0, "There should be a link"); + assert.strictEqual(output.indexOf('</a>'), (output.length - 4), "Link should match the whole text"); + } else { + assert.strictEqual(output.indexOf('<a '), -1, "There should be no link"); + } + }); +}); + +QUnit.test('addLink: linkify inside text node (1 occurrence)', function (assert) { + assert.expect(5); + + const content = '<p>some text https://somelink.com</p>'; + const linkified = utils.parseAndTransform(content, utils.addLink); + assert.ok( + linkified.startsWith('<p>some text <a'), + "linkified text should start with non-linkified start part, followed by an '<a>' tag" + ); + assert.ok( + linkified.endsWith('</a></p>'), + "linkified text should end with closing '<a>' tag" + ); + + // linkify may add some attributes. Since we do not care of their exact + // stringified representation, we continue deeper assertion with query + // selectors. + const fragment = document.createDocumentFragment(); + const div = document.createElement('div'); + fragment.appendChild(div); + div.innerHTML = linkified; + assert.strictEqual( + div.textContent, + 'some text https://somelink.com', + "linkified text should have same text content as non-linkified version" + ); + assert.strictEqual( + div.querySelectorAll(':scope a').length, + 1, + "linkified text should have an <a> tag" + ); + assert.strictEqual( + div.querySelector(':scope a').textContent, + 'https://somelink.com', + "text content of link should be equivalent of its non-linkified version" + ); +}); + +QUnit.test('addLink: linkify inside text node (2 occurrences)', function (assert) { + assert.expect(4); + + // linkify may add some attributes. Since we do not care of their exact + // stringified representation, we continue deeper assertion with query + // selectors. + const content = '<p>some text https://somelink.com and again https://somelink2.com ...</p>'; + const linkified = utils.parseAndTransform(content, utils.addLink); + const fragment = document.createDocumentFragment(); + const div = document.createElement('div'); + fragment.appendChild(div); + div.innerHTML = linkified; + assert.strictEqual( + div.textContent, + 'some text https://somelink.com and again https://somelink2.com ...', + "linkified text should have same text content as non-linkified version" + ); + assert.strictEqual( + div.querySelectorAll(':scope a').length, + 2, + "linkified text should have 2 <a> tags" + ); + assert.strictEqual( + div.querySelectorAll(':scope a')[0].textContent, + 'https://somelink.com', + "text content of 1st link should be equivalent to its non-linkified version" + ); + assert.strictEqual( + div.querySelectorAll(':scope a')[1].textContent, + 'https://somelink2.com', + "text content of 2nd link should be equivalent to its non-linkified version" + ); +}); + +}); +}); diff --git a/addons/mail/static/tests/many2one_avatar_user_tests.js b/addons/mail/static/tests/many2one_avatar_user_tests.js new file mode 100644 index 00000000..7010cc9a --- /dev/null +++ b/addons/mail/static/tests/many2one_avatar_user_tests.js @@ -0,0 +1,123 @@ +odoo.define('mail.Many2OneAvatarUserTests', function (require) { +"use strict"; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); + +const KanbanView = require('web.KanbanView'); +const ListView = require('web.ListView'); +const { Many2OneAvatarUser } = require('mail.Many2OneAvatarUser'); +const { dom, mock } = require('web.test_utils'); + + +QUnit.module('mail', {}, function () { + QUnit.module('Many2OneAvatarUser', { + beforeEach() { + beforeEach(this); + + // reset the cache before each test + Many2OneAvatarUser.prototype.partnerIds = {}; + + Object.assign(this.data, { + 'foo': { + fields: { + user_id: { string: "User", type: 'many2one', relation: 'res.users' }, + }, + records: [ + { id: 1, user_id: 11 }, + { id: 2, user_id: 7 }, + { id: 3, user_id: 11 }, + { id: 4, user_id: 23 }, + ], + }, + }); + + this.data['res.partner'].records.push( + { id: 11, display_name: "Partner 1" }, + { id: 12, display_name: "Partner 2" }, + { id: 13, display_name: "Partner 3" } + ); + this.data['res.users'].records.push( + { id: 11, name: "Mario", partner_id: 11 }, + { id: 7, name: "Luigi", partner_id: 12 }, + { id: 23, name: "Yoshi", partner_id: 13 } + ); + }, + afterEach() { + afterEach(this); + }, + }); + + QUnit.test('many2one_avatar_user widget in list view', async function (assert) { + assert.expect(5); + + const { widget: list } = await start({ + hasView: true, + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree><field name="user_id" widget="many2one_avatar_user"/></tree>', + mockRPC(route, args) { + if (args.method === 'read') { + assert.step(`read ${args.model} ${args.args[0]}`); + } + return this._super(...arguments); + }, + }); + + mock.intercept(list, 'open_record', () => { + assert.step('open record'); + }); + + assert.strictEqual(list.$('.o_data_cell span').text(), 'MarioLuigiMarioYoshi'); + + // sanity check: later on, we'll check that clicking on the avatar doesn't open the record + await dom.click(list.$('.o_data_row:first span')); + + await dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar')); + await dom.click(list.$('.o_data_cell:nth(1) .o_m2o_avatar')); + await dom.click(list.$('.o_data_cell:nth(2) .o_m2o_avatar')); + + + assert.verifySteps([ + 'open record', + 'read res.users 11', + // 'call service openDMChatWindow 1', + 'read res.users 7', + // 'call service openDMChatWindow 2', + // 'call service openDMChatWindow 1', + ]); + + list.destroy(); + }); + + QUnit.test('many2one_avatar_user widget in kanban view', async function (assert) { + assert.expect(6); + + const { widget: kanban } = await start({ + hasView: true, + View: KanbanView, + model: 'foo', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div> + <field name="user_id" widget="many2one_avatar_user"/> + </div> + </t> + </templates> + </kanban>`, + }); + + assert.strictEqual(kanban.$('.o_kanban_record').text().trim(), ''); + assert.containsN(kanban, '.o_m2o_avatar', 4); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(0)').data('src'), '/web/image/res.users/11/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(1)').data('src'), '/web/image/res.users/7/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(2)').data('src'), '/web/image/res.users/11/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(3)').data('src'), '/web/image/res.users/23/image_128'); + + kanban.destroy(); + }); +}); +}); diff --git a/addons/mail/static/tests/systray/systray_activity_menu_tests.js b/addons/mail/static/tests/systray/systray_activity_menu_tests.js new file mode 100644 index 00000000..f1d11ea1 --- /dev/null +++ b/addons/mail/static/tests/systray/systray_activity_menu_tests.js @@ -0,0 +1,276 @@ +odoo.define('mail.systray.ActivityMenuTests', function (require) { +"use strict"; + +const { + afterEach, + afterNextRender, + beforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); +var ActivityMenu = require('mail.systray.ActivityMenu'); + +var testUtils = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('ActivityMenu', { + beforeEach() { + beforeEach(this); + + Object.assign(this.data, { + 'mail.activity.menu': { + fields: { + name: { type: "char" }, + model: { type: "char" }, + type: { type: "char" }, + planned_count: { type: "integer" }, + today_count: { type: "integer" }, + overdue_count: { type: "integer" }, + total_count: { type: "integer" }, + actions: [{ + icon: { type: "char" }, + name: { type: "char" }, + action_xmlid: { type: "char" }, + }], + }, + records: [{ + name: "Contact", + model: "res.partner", + type: "activity", + planned_count: 0, + today_count: 1, + overdue_count: 0, + total_count: 1, + }, + { + name: "Task", + type: "activity", + model: "project.task", + planned_count: 1, + today_count: 0, + overdue_count: 0, + total_count: 1, + }, + { + name: "Issue", + type: "activity", + model: "project.issue", + planned_count: 1, + today_count: 1, + overdue_count: 1, + total_count: 3, + actions: [{ + icon: "fa-clock-o", + name: "summary", + }], + }, + { + name: "Note", + type: "activity", + model: "partner", + planned_count: 1, + today_count: 1, + overdue_count: 1, + total_count: 3, + actions: [{ + icon: "fa-clock-o", + name: "summary", + action_xmlid: "mail.mail_activity_type_view_tree", + }], + } + ], + }, + }); + this.session = { + uid: 10, + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('activity menu widget: menu with no records', async function (assert) { + assert.expect(1); + + const { widget } = await start({ + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'systray_get_activities') { + return Promise.resolve([]); + } + return this._super(route, args); + }, + }); + const activityMenu = new ActivityMenu(widget); + await activityMenu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + assert.containsOnce(activityMenu, '.o_no_activity'); + widget.destroy(); +}); + +QUnit.test('activity menu widget: activity menu with 3 records', async function (assert) { + assert.expect(10); + var self = this; + + const { widget } = await start({ + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'systray_get_activities') { + return Promise.resolve(self.data['mail.activity.menu']['records']); + } + return this._super(route, args); + }, + }); + var activityMenu = new ActivityMenu(widget); + await activityMenu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + assert.hasClass(activityMenu.$el, 'o_mail_systray_item', 'should be the instance of widget'); + // the assertion below has not been replace because there are includes of ActivityMenu that modify the length. + assert.ok(activityMenu.$('.o_mail_preview').length); + assert.containsOnce(activityMenu.$el, '.o_notification_counter', "widget should have notification counter"); + assert.strictEqual(parseInt(activityMenu.el.innerText), 8, "widget should have 8 notification counter"); + + var context = {}; + testUtils.mock.intercept(activityMenu, 'do_action', function (event) { + assert.deepEqual(event.data.action.context, context, "wrong context value"); + }, true); + + // case 1: click on "late" + context = { + force_search_count: 1, + search_default_activities_overdue: 1, + }; + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + assert.hasClass(activityMenu.$el, 'show', 'ActivityMenu should be open'); + await testUtils.dom.click(activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='overdue']")); + assert.doesNotHaveClass(activityMenu.$el, 'show', 'ActivityMenu should be closed'); + // case 2: click on "today" + context = { + force_search_count: 1, + search_default_activities_today: 1, + }; + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + await testUtils.dom.click(activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='today']")); + // case 3: click on "future" + context = { + force_search_count: 1, + search_default_activities_upcoming_all: 1, + }; + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + await testUtils.dom.click(activityMenu.$(".o_activity_filter_button[data-model_name='Issue'][data-filter='upcoming_all']")); + // case 4: click anywere else + context = { + force_search_count: 1, + search_default_activities_overdue: 1, + search_default_activities_today: 1, + }; + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + await testUtils.dom.click(activityMenu.$(".o_mail_systray_dropdown_items > div[data-model_name='Issue']")); + + widget.destroy(); +}); + +QUnit.test('activity menu widget: activity view icon', async function (assert) { + assert.expect(12); + var self = this; + + const { widget } = await start({ + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'systray_get_activities') { + return Promise.resolve(self.data['mail.activity.menu'].records); + } + return this._super(route, args); + }, + session: this.session, + }); + var activityMenu = new ActivityMenu(widget); + await activityMenu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + assert.containsN(activityMenu, '.o_mail_activity_action', 2, + "widget should have 2 activity view icons"); + + var $first = activityMenu.$('.o_mail_activity_action').eq(0); + var $second = activityMenu.$('.o_mail_activity_action').eq(1); + assert.strictEqual($first.data('model_name'), "Issue", + "first activity action should link to 'Issue'"); + assert.hasClass($first, 'fa-clock-o', "should display the activity action icon"); + + assert.strictEqual($second.data('model_name'), "Note", + "Second activity action should link to 'Note'"); + assert.hasClass($second, 'fa-clock-o', "should display the activity action icon"); + + testUtils.mock.intercept(activityMenu, 'do_action', function (ev) { + if (ev.data.action.name) { + assert.ok(ev.data.action.domain, "should define a domain on the action"); + assert.deepEqual(ev.data.action.domain, [["activity_ids.user_id", "=", 10]], + "should set domain to user's activity only"); + assert.step('do_action:' + ev.data.action.name); + } else { + assert.step('do_action:' + ev.data.action); + } + }, true); + + // click on the "Issue" activity icon + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + assert.hasClass(activityMenu.$('.dropdown-menu'), 'show', + "dropdown should be expanded"); + + await testUtils.dom.click(activityMenu.$(".o_mail_activity_action[data-model_name='Issue']")); + assert.doesNotHaveClass(activityMenu.$('.dropdown-menu'), 'show', + "dropdown should be collapsed"); + + // click on the "Note" activity icon + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + await testUtils.dom.click(activityMenu.$(".o_mail_activity_action[data-model_name='Note']")); + + assert.verifySteps([ + 'do_action:Issue', + 'do_action:mail.mail_activity_type_view_tree' + ]); + + widget.destroy(); +}); + +QUnit.test('activity menu widget: close on messaging menu click', async function (assert) { + assert.expect(2); + + const { widget } = await start({ + data: this.data, + hasMessagingMenu: true, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return []; + } + if (args.method === 'systray_get_activities') { + return []; + } + return this._super(route, args); + }, + }); + const activityMenu = new ActivityMenu(widget); + await activityMenu.appendTo($('#qunit-fixture')); + await testUtils.nextTick(); + + await testUtils.dom.click(activityMenu.$('.dropdown-toggle')); + assert.hasClass( + activityMenu.el.querySelector('.o_mail_systray_dropdown'), + 'show', + "activity menu should be shown after click on itself" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.doesNotHaveClass( + activityMenu.el.querySelector('.o_mail_systray_dropdown'), + 'show', + "activity menu should be hidden after click on messaging menu" + ); + + widget.destroy(); +}); + +}); + +}); diff --git a/addons/mail/static/tests/tools/debug_manager_tests.js b/addons/mail/static/tests/tools/debug_manager_tests.js new file mode 100644 index 00000000..151b8902 --- /dev/null +++ b/addons/mail/static/tests/tools/debug_manager_tests.js @@ -0,0 +1,64 @@ +odoo.define('mail.debugManagerTests', function (require) { +"use strict"; + +var testUtils = require('web.test_utils'); + +var createDebugManager = testUtils.createDebugManager; + +QUnit.module('Mail DebugManager', {}, function () { + + QUnit.test("Manage Messages", async function (assert) { + assert.expect(3); + + var debugManager = await createDebugManager({ + intercepts: { + do_action: function (event) { + assert.deepEqual(event.data.action, { + context: { + default_res_model: "testModel", + default_res_id: 5, + }, + res_model: 'mail.message', + name: "Manage Messages", + views: [[false, 'list'], [false, 'form']], + type: 'ir.actions.act_window', + domain: [['res_id', '=', 5], ['model', '=', 'testModel']], + }); + }, + }, + }); + + await debugManager.appendTo($('#qunit-fixture')); + + // Simulate update debug manager from web client + var action = { + views: [{ + displayName: "Form", + fieldsView: { + view_id: 1, + }, + type: "form", + }], + }; + var view = { + viewType: "form", + getSelectedIds: function () { + return [5]; + }, + modelName: 'testModel', + }; + await testUtils.nextTick(); + await debugManager.update('action', action, view); + + var $messageMenu = debugManager.$('a[data-action=getMailMessages]'); + assert.strictEqual($messageMenu.length, 1, "should have Manage Message menu item"); + assert.strictEqual($messageMenu.text().trim(), "Manage Messages", + "should have correct menu item text"); + + await testUtils.dom.click(debugManager.$('> a')); // open dropdown + await testUtils.dom.click($messageMenu); + + debugManager.destroy(); + }); +}); +}); diff --git a/addons/mail/static/tests/tours/mail_full_composer_test_tour.js b/addons/mail/static/tests/tours/mail_full_composer_test_tour.js new file mode 100644 index 00000000..6d9e519e --- /dev/null +++ b/addons/mail/static/tests/tours/mail_full_composer_test_tour.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/tests/tours/mail_full_composer_test_tour.js', function (require) { +"use strict"; + +const { + createFile, + inputFiles, +} = require('web.test_utils_file'); + +const tour = require('web_tour.tour'); + +/** + * This tour depends on data created by python test in charge of launching it. + * It is not intended to work when launched from interface. It is needed to test + * an action (action manager) which is not possible to test with QUnit. + * @see mail/tests/test_mail_full_composer.py + */ +tour.register('mail/static/tests/tours/mail_full_composer_test_tour.js', { + test: true, +}, [{ + content: "Click on Send Message", + trigger: '.o_ChatterTopbar_buttonSendMessage', +}, { + content: "Write something in composer", + trigger: '.o_ComposerTextInput_textarea', + run: 'text blahblah', +}, { + content: "Add one file in composer", + trigger: '.o_Composer_buttonAttachment', + async run() { + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ); + }, +}, { + content: "Open full composer", + trigger: '.o_Composer_buttonFullComposer', + extra_trigger: '.o_Attachment:not(.o-temporary)' // waiting the attachment to be uploaded +}, { + content: "Check the earlier provided attachment is listed", + trigger: '.o_attachment[title="text.txt"]', + run() {}, +}, { + content: "Check subject is autofilled", + trigger: 'input[name="subject"]', + run() { + const subjectValue = document.querySelector('input[name="subject"]').value; + if (subjectValue !== "Re: Test User") { + console.error( + `Full composer should have "Re: Test User" in subject input (actual: ${subjectValue})` + ); + } + }, +}, { + content: "Check composer content is kept", + trigger: '.oe_form_field[name="body"]', + run() { + const bodyContent = document.querySelector('.oe_form_field[name="body"] textarea').textContent; + if (!bodyContent.includes("blahblah")) { + console.error( + `Full composer should contain text from small composer ("blahblah") in body input (actual: ${bodyContent})` + ); + } + }, +}, { + content: "Open templates", + trigger: '.o_field_widget[name="template_id"] input', +}, { + content: "Check a template is listed", + in_modal: false, + trigger: '.ui-autocomplete .ui-menu-item a:contains("Test template")', + run() {}, +}, { + content: "Send message", + trigger: '.o_mail_send', +}, { + content: "Check message is shown", + trigger: '.o_Message:contains("blahblah")', +}, { + content: "Check message contains the attachment", + trigger: '.o_Message .o_Attachment_filename:contains("text.txt")', +}]); + +}); |
