From 97cc94269d9853b49f07dfd524195f23a798dd4a Mon Sep 17 00:00:00 2001 From: luvi Date: Mon, 12 Dec 2022 22:10:11 +0000 Subject: [PATCH] [REF] mail, *: convert Activity view to Owl *: project, web This commit converts the Activity view to the current framework shared by other views. Instead of extending Kanban view/elements, the Activity view has been implemented by its own. The project customization of the view has also been converted, as legacy files have been removed. Some tests have been adapted since some classnames might differ from the old implementation, resulting to failing tests. But the overall testing cases have been conserved. closes odoo/odoo#107916 Related: odoo/enterprise#35177 Signed-off-by: Aaron Bohy (aab) Co-authored-by: Florent Dardenne --- .../js/views/activity/activity_controller.js | 138 -- .../src/js/views/activity/activity_model.js | 86 -- .../src/js/views/activity/activity_record.js | 61 - .../js/views/activity/activity_renderer.js | 206 --- .../src/js/views/activity/activity_view.js | 53 - .../views/activity/activity_arch_parser.js | 63 + .../src/views/activity/activity_compiler.js | 72 + .../src/views/activity/activity_controller.js | 115 ++ .../activity/activity_controller.scss} | 58 +- .../views/activity/activity_controller.xml | 10 + .../src/views/activity/activity_model.js | 17 + .../src/views/activity/activity_record.js | 49 + .../src/views/activity/activity_record.xml | 10 + .../src/views/activity/activity_renderer.js | 119 ++ .../src/views/activity/activity_renderer.xml | 86 ++ .../src/views/activity/activity_view.js | 30 + addons/mail/static/src/xml/activity_view.xml | 93 -- addons/project/__manifest__.py | 1 - .../project/static/src/js/project_activity.js | 13 - .../static/src/scss/project_dashboard.scss | 2 +- .../project_activity/project_activity_view.js | 11 + .../test_mail/static/tests/activity_tests.js | 1297 ++++++++++------- .../src/views/basic_relational_model.js | 12 + .../static/src/views/kanban/kanban_record.js | 63 +- .../web/static/src/views/relational_model.js | 54 + addons/web/static/src/views/view_compiler.js | 8 +- 26 files changed, 1390 insertions(+), 1337 deletions(-) delete mode 100644 addons/mail/static/src/js/views/activity/activity_controller.js delete mode 100644 addons/mail/static/src/js/views/activity/activity_model.js delete mode 100644 addons/mail/static/src/js/views/activity/activity_record.js delete mode 100644 addons/mail/static/src/js/views/activity/activity_renderer.js delete mode 100644 addons/mail/static/src/js/views/activity/activity_view.js create mode 100644 addons/mail/static/src/views/activity/activity_arch_parser.js create mode 100644 addons/mail/static/src/views/activity/activity_compiler.js create mode 100644 addons/mail/static/src/views/activity/activity_controller.js rename addons/mail/static/src/{scss/activity_view.scss => views/activity/activity_controller.scss} (61%) create mode 100644 addons/mail/static/src/views/activity/activity_controller.xml create mode 100644 addons/mail/static/src/views/activity/activity_model.js create mode 100644 addons/mail/static/src/views/activity/activity_record.js create mode 100644 addons/mail/static/src/views/activity/activity_record.xml create mode 100644 addons/mail/static/src/views/activity/activity_renderer.js create mode 100644 addons/mail/static/src/views/activity/activity_renderer.xml create mode 100644 addons/mail/static/src/views/activity/activity_view.js delete mode 100644 addons/mail/static/src/xml/activity_view.xml delete mode 100644 addons/project/static/src/js/project_activity.js create mode 100644 addons/project/static/src/views/project_activity/project_activity_view.js diff --git a/addons/mail/static/src/js/views/activity/activity_controller.js b/addons/mail/static/src/js/views/activity/activity_controller.js deleted file mode 100644 index a7a1cae5b00..00000000000 --- a/addons/mail/static/src/js/views/activity/activity_controller.js +++ /dev/null @@ -1,138 +0,0 @@ -/** @odoo-module **/ - -import "@mail/js/activity"; - -import BasicController from "web.BasicController"; -import core from "web.core"; -import { sprintf } from "@web/core/utils/strings"; - -import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog"; - -import { Component } from "@odoo/owl"; -var _t = core._t; - -var ActivityController = BasicController.extend({ - custom_events: _.extend({}, BasicController.prototype.custom_events, { - empty_cell_clicked: "_onEmptyCell", - reload_data: "_onReloadData", - 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; - this.searchViewId = params.searchViewId; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Overridden to remove the pager as it makes no sense in this view. - * - * @override - */ - _getPagingInfo: function () { - return null; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onReloadData() { - this.trigger_up("reload"); - }, - /** - * @private - */ - _onScheduleActivity: function () { - var state = this.model.get(this.handle); - Component.env.services.dialog.add(SelectCreateDialog, { - resModel: state.model, - searchViewId: this.searchViewId, - domain: this.model.originalDomain, - title: sprintf(_t("Search: %s"), this.title), - noCreate: !this.activeActions.create, - multiSelect: false, - context: state.context, - onSelected: async (resIds) => { - const messaging = await owl.Component.env.services.messaging.get(); - const thread = messaging.models["Thread"].insert({ - id: resIds[0], - model: this.model.modelName, - }); - await messaging.openActivityForm({ thread }); - this.trigger_up("reload"); - }, - }); - }, - /** - * @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], - }); - }, -}); - -export default 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 deleted file mode 100644 index ad02489e756..00000000000 --- a/addons/mail/static/src/js/views/activity/activity_model.js +++ /dev/null @@ -1,86 +0,0 @@ -/** @odoo-module **/ - -import BasicModel from "web.BasicModel"; -import session from "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); - } - return result; - }, - /** - * @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; - }); - }, -}); - -export default 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 deleted file mode 100644 index 8bc4e9a7477..00000000000 --- a/addons/mail/static/src/js/views/activity/activity_record.js +++ /dev/null @@ -1,61 +0,0 @@ -/** @odoo-module **/ - -import KanbanRecord from "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, - }; - }, -}); - -export default 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 deleted file mode 100644 index 0526098d67a..00000000000 --- a/addons/mail/static/src/js/views/activity/activity_renderer.js +++ /dev/null @@ -1,206 +0,0 @@ -/** @odoo-module **/ - -import ActivityRecord from "@mail/js/views/activity/activity_record"; -import { ActivityCellViewContainer } from "@mail/backend_components/activity_cell_view/activity_cell_view_container"; - -import AbstractRendererOwl from "web.AbstractRendererOwl"; -import core from "web.core"; -import KanbanColumnProgressBar from "web.KanbanColumnProgressBar"; -import { ComponentAdapter } from "web.OwlCompatibility"; -import QWeb from "web.QWeb"; -import session from "web.session"; -import utils from "web.utils"; - -import { useState } from "@odoo/owl"; -const _t = core._t; - -/** - * 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 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) { - if (ev.name === "set_progress_bar_state") { - this.props.onSetProgressBarState( - new CustomEvent("set-progress-bar-state", { - bubbles: true, - cancelable: true, - detail: ev.data, - }) - ); - } - super._trigger_up(ev); - } - } -} - -class ActivityRenderer extends AbstractRendererOwl { - setup() { - super.setup(...arguments); - this.qweb = new QWeb(this.env.isDebug(), { _s: session.origin }); - this.qweb.add_template(utils.json_node_to_xml(this.props.templates)); - this.activeFilter = useState({ - state: null, - activityTypeId: null, - resIds: [], - }); - this.widgetComponents = { - ActivityRecord, - 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 (const 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.value - ) - .map(([key]) => parseInt(key)); - } else { - this.activeFilter.state = null; - this.activeFilter.activityTypeId = null; - if (this.activeFilter.resIds.length > 0) { - // writing a new array is a state mutation which triggers a rerender - // only replace resIds with empty array if it's not already empty - this.activeFilter.resIds = []; - } - } - } -} - -ActivityRenderer.components = { - ActivityCellViewContainer, - ActivityRecordAdapter, - KanbanColumnProgressBarAdapter, -}; -ActivityRenderer.template = "mail.ActivityRenderer"; - -export default 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 deleted file mode 100644 index ab89493ce52..00000000000 --- a/addons/mail/static/src/js/views/activity/activity_view.js +++ /dev/null @@ -1,53 +0,0 @@ -/** @odoo-module **/ - -import ActivityController from "@mail/js/views/activity/activity_controller"; -import ActivityModel from "@mail/js/views/activity/activity_model"; -import ActivityRenderer from "@mail/js/views/activity/activity_renderer"; - -import BasicView from "web.BasicView"; -import core from "web.core"; -import RendererWrapper from "web.RendererWrapper"; -import view_registry from "web.view_registry"; - -const _lt = core._lt; - -const ActivityView = BasicView.extend({ - accesskey: "a", - display_name: _lt("Activity"), - icon: "fa fa-clock-o", - config: _.extend({}, BasicView.prototype.config, { - Controller: ActivityController, - Model: ActivityModel, - Renderer: ActivityRenderer, - }), - viewType: "activity", - searchMenuTypes: ["filter", "favorite"], - - /** - * @override - */ - init: function (viewInfo, params) { - this._super.apply(this, arguments); - - const { search_view_id } = params.action || {}; - this.controllerParams.searchViewId = search_view_id ? search_view_id[0] : false; - 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); - -export default ActivityView; diff --git a/addons/mail/static/src/views/activity/activity_arch_parser.js b/addons/mail/static/src/views/activity/activity_arch_parser.js new file mode 100644 index 00000000000..adbe2ffb418 --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_arch_parser.js @@ -0,0 +1,63 @@ +/** @odoo-module */ + +import { addFieldDependencies } from "@web/views/utils"; +import { Field } from "@web/views/fields/field"; +import { XMLParser } from "@web/core/utils/xml"; + +export class ActivityArchParser extends XMLParser { + parse(arch, models, modelName) { + const xmlDoc = this.parseXML(arch); + const jsClass = xmlDoc.getAttribute("js_class"); + const title = xmlDoc.getAttribute("string"); + + const fieldNodes = {}; + const templateDocs = {}; + const activeFields = {}; + + this.visitXML(xmlDoc, (node) => { + if (node.hasAttribute("t-name")) { + templateDocs[node.getAttribute("t-name")] = node; + return; + } + + if (node.tagName === "field") { + const fieldInfo = Field.parseFieldNode( + node, + models, + modelName, + "activity", + jsClass + ); + const name = fieldInfo.name; + fieldNodes[name] = fieldInfo; + node.setAttribute("field_id", name); + + addFieldDependencies( + activeFields, + models[modelName], + fieldInfo.FieldComponent.fieldDependencies + ); + } + + // Keep track of last update so images can be reloaded when they may have changed. + if (node.tagName === "img") { + const attSrc = node.getAttribute("t-att-src"); + if (attSrc && /\bactivity_image\b/.test(attSrc) && !fieldNodes.write_date) { + fieldNodes.write_date = { type: "datetime" }; + } + } + + for (const [key, field] of Object.entries(fieldNodes)) { + activeFields[key] = field; + } + }); + return { + arch, + activeFields, + fieldNodes, + templateDocs, + title, + __rawArch: arch, + }; + } +} diff --git a/addons/mail/static/src/views/activity/activity_compiler.js b/addons/mail/static/src/views/activity/activity_compiler.js new file mode 100644 index 00000000000..b577774731e --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_compiler.js @@ -0,0 +1,72 @@ +/** @odoo-module **/ + +import { createElement } from "@web/core/utils/xml"; +import { toStringExpression } from "@web/views/utils"; +import { toInterpolatedStringExpression, ViewCompiler } from "@web/views/view_compiler"; + +export class ActivityCompiler extends ViewCompiler { + /** + * @override + */ + compileField(el, params) { + let compiled; + if (!el.hasAttribute("widget")) { + // fields without a specified widget are rendered as simple spans in activity records + compiled = createElement("div", { "t-out": `record["${el.getAttribute("name")}"].value` }); + if (el.getAttribute("muted")) { + compiled.classList.add("text-muted"); + } + } else { + compiled = super.compileField(el, params); + } + + const attrs = {}; + for (const attr of el.attributes) { + attrs[attr.name] = attr.value; + } + + if (el.hasAttribute("widget")) { + const attrsParts = Object.entries(attrs).map(([key, value]) => { + if (key.startsWith("t-attf-")) { + key = key.slice(7); + value = toInterpolatedStringExpression(value); + } else if (key.startsWith("t-att-")) { + key = key.slice(6); + value = `"" + (${value})`; + } else if (key.startsWith("t-att")) { + throw new Error("t-att on nodes is not supported"); + } else if (!key.startsWith("t-")) { + value = toStringExpression(value); + } + return `'${key}':${value}`; + }); + compiled.setAttribute("attrs", `{${attrsParts.join(",")}}`); + } + + for (const attr in attrs) { + if (attr.startsWith("t-") && !attr.startsWith("t-att")) { + compiled.setAttribute(attr, attrs[attr]); + } + } + + return compiled; + } +} + +ActivityCompiler.OWL_DIRECTIVE_WHITELIST = [ + ...ViewCompiler.OWL_DIRECTIVE_WHITELIST, + "t-name", + "t-esc", + "t-out", + "t-set", + "t-value", + "t-if", + "t-else", + "t-elif", + "t-foreach", + "t-as", + "t-key", + "t-att.*", + "t-call", + "t-translation", +]; diff --git a/addons/mail/static/src/views/activity/activity_controller.js b/addons/mail/static/src/views/activity/activity_controller.js new file mode 100644 index 00000000000..0c0f2645f00 --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_controller.js @@ -0,0 +1,115 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { Layout } from "@web/search/layout"; +import { standardViewProps } from "@web/views/standard_view_props"; +import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog"; +import { sprintf } from "@web/core/utils/strings"; +import { useModel } from "@web/views/model"; +import { useService } from "@web/core/utils/hooks"; + +export class ActivityController extends Component { + static components = { Layout }; + static props = { + ...standardViewProps, + Model: Function, + Renderer: Function, + archInfo: Object, + }; + static template = "mail.ActivityController"; + + setup() { + const { rootState } = this.props.state || {}; + this.model = useModel( + this.props.Model, + { + activeFields: this.props.archInfo.activeFields, + resModel: this.props.resModel, + fields: this.props.fields, + viewMode: "activity", + rootState, + }, + { ignoreUseSampleModel: true } + ); + + this.dialog = useService("dialog"); + this.action = useService("action"); + this.messaging = useService("messaging"); + } + + scheduleActivity() { + this.dialog.add(SelectCreateDialog, { + resModel: this.props.resModel, + searchViewId: this.env.searchModel.searchViewId, + domain: this.props.domain, + title: sprintf(this.env._t("Search: %s"), this.props.archInfo.title), + multiSelect: false, + context: this.props.context, + onSelected: async (resIds) => { + const messaging = await this.messaging.get(); + const thread = messaging.models["Thread"].insert({ + id: resIds[0], + model: this.props.resModel, + }); + await messaging.openActivityForm({ thread }); + this.model.load(); + }, + }); + } + + openActivityFormView(resId, activityTypeId) { + this.action.doAction( + { + type: "ir.actions.act_window", + res_model: "mail.activity", + views: [[false, "form"]], + view_mode: "form", + view_type: "form", + res_id: false, + target: "new", + context: { + default_res_id: resId, + default_res_model: this.props.resModel, + default_activity_type_id: activityTypeId, + }, + }, + { + onClose: () => this.model.load(), + } + ); + } + + sendMailTemplate(templateID, activityTypeID) { + const groupedActivities = this.model.activityData.grouped_activities; + const resIds = []; + for (const resId in groupedActivities) { + const activityByType = groupedActivities[resId]; + const activity = activityByType[activityTypeID]; + if (activity) { + resIds.push(parseInt(resId)); + } + } + this.model.orm.call(this.props.resModel, "activity_send_mail", [resIds, templateID], {}); + } + + async openRecord(record, mode) { + const activeIds = this.model.root.records.map((datapoint) => datapoint.resId); + this.props.selectRecord(record.resId, { activeIds, mode }); + } + + get rendererProps() { + return { + activityTypes: this.model.activityData.activity_types, + activityResIds: this.model.activityData.activity_res_ids, + fields: this.model.root.fields, + records: this.model.root.records, + archInfo: this.props.archInfo, + groupedActivities: this.model.activityData.grouped_activities, + scheduleActivity: this.scheduleActivity.bind(this), + onReloadData: () => this.model.load(), + onEmptyCell: this.openActivityFormView.bind(this), + onSendMailTemplate: this.sendMailTemplate.bind(this), + openRecord: this.openRecord.bind(this), + }; + } +} diff --git a/addons/mail/static/src/scss/activity_view.scss b/addons/mail/static/src/views/activity/activity_controller.scss similarity index 61% rename from addons/mail/static/src/scss/activity_view.scss rename to addons/mail/static/src/views/activity/activity_controller.scss index 712c53fc440..d8a8245c6ff 100644 --- a/addons/mail/static/src/scss/activity_view.scss +++ b/addons/mail/static/src/views/activity/activity_controller.scss @@ -1,13 +1,10 @@ .o_activity_view { - height: 100%; > table { thead > tr > th:first-of-type { min-width: 300px; } } .o_activity_summary_cell { - padding: 0; // to cancel unwanted padding of coming from bootstrap - &.planned { background-color: map-get($theme-colors, 'success'); } @@ -26,19 +23,17 @@ > i { color: gray; - display: block; + display: flex; } } } } // it contains a kanban card representing the record - .o_activity_record { + .o_activity_record > div { display: flex; flex: 1 1 auto; align-items: center; - padding: 8px 8px; - cursor: pointer; .o_m2o_avatar > img, > img { width: 32px; @@ -74,7 +69,6 @@ background-color: mix(map-get($theme-colors, 'danger'), $o-webclient-background-color, 5%); } .o_activity_type_cell { - padding:10px; min-width:100px; .fa-ellipsis-v { cursor: pointer; @@ -88,9 +82,9 @@ color: o-text-color('success'); } } - .o_kanban_counter { + .o_activity_counter { margin: 5px 0 0 0; - > .o_kanban_counter_progress { + > .o_column_progress { width: 100%; > div.active { border: 1px solid; @@ -98,50 +92,6 @@ } } } - - .o_legacy_kanban_counter { - position: relative; - display: flex; - align-items: center; - transition: opacity 0.3s ease 0s; - margin-bottom: $o-kanban-record-margin*2; - - > .o_kanban_counter_progress { - width: 76%; - height: $font-size-sm; - margin-bottom: 0; - background-color: map-get($grays, '300'); - box-shadow: none; - - .progress-bar { - margin-bottom: 0; - box-shadow: none; - cursor: pointer; - } - } - - > .o_kanban_counter_side { - width: 21%; - margin-left: 3%; - color: $headings-color; - text-align: right; - white-space: nowrap; - transform-origin: right center; - - &.o_kanban_grow { - animation: legacy-kanban-counter-grow 1s ease 0s 1 normal none running; - } - - &.o_kanban_grow_huge { - animation: legacy-kanban-counter-grow-huge 1s ease 0s 1 normal none running; - } - - // Target currency icon - > span { - margin-left: 2px; - } - } - } } .o_activity_view_table { diff --git a/addons/mail/static/src/views/activity/activity_controller.xml b/addons/mail/static/src/views/activity/activity_controller.xml new file mode 100644 index 00000000000..c0707e7ec9b --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_controller.xml @@ -0,0 +1,10 @@ + + + +
+ + + +
+
+
diff --git a/addons/mail/static/src/views/activity/activity_model.js b/addons/mail/static/src/views/activity/activity_model.js new file mode 100644 index 00000000000..0199ab8d41a --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_model.js @@ -0,0 +1,17 @@ +/** @odoo-module */ + +import { RelationalModel } from "@web/views/relational_model"; + +export class ActivityModel extends RelationalModel { + async load(params = {}) { + this.activityData = await this.fetchActivityData(params); + await super.load(params); + } + + fetchActivityData(params) { + return this.orm.call("mail.activity", "get_activity_data", [], { + res_model: this.rootParams.resModel, + domain: params.domain || this.env.searchModel._domain, + }); + } +} diff --git a/addons/mail/static/src/views/activity/activity_record.js b/addons/mail/static/src/views/activity/activity_record.js new file mode 100644 index 00000000000..3faf1ddfe84 --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_record.js @@ -0,0 +1,49 @@ +/** @odoo-module */ + +import { ActivityCompiler } from "./activity_compiler"; +import { Component } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { Field } from "@web/views/fields/field"; +import { + getImageSrcFromRecordInfo, + isHtmlEmpty, +} from "@web/views/kanban/kanban_record"; +import { useViewCompiler } from "@web/views/view_compiler"; + +export class ActivityRecord extends Component { + static components = { + Field, + }; + static props = { + archInfo: { type: Object }, + openRecord: { type: Function }, + record: { type: Object }, + }; + static template = "mail.ActivityRecord"; + + setup() { + this.user = useService("user"); + this.widget = { + deletable: false, + editable: false, + isHtmlEmpty, + }; + const { arch, templateDocs } = this.props.archInfo; + const compileParams = { + recordExpr: `record`, + }; + this.recordTemplate = useViewCompiler(ActivityCompiler, arch, templateDocs, compileParams)[ + "activity-box" + ]; + } + + getRenderingContext() { + const { record } = this.props; + return { + record: record.formattedRecord, + activity_image: (...args) => getImageSrcFromRecordInfo(record, ...args), + user_context: this.user.context, + widget: this.widget, + }; + } +} diff --git a/addons/mail/static/src/views/activity/activity_record.xml b/addons/mail/static/src/views/activity/activity_record.xml new file mode 100644 index 00000000000..0b1ab6b4429 --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_record.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/mail/static/src/views/activity/activity_renderer.js b/addons/mail/static/src/views/activity/activity_renderer.js new file mode 100644 index 00000000000..fc37ca14d1a --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_renderer.js @@ -0,0 +1,119 @@ +/** @odoo-module */ + +import { ActivityCellViewContainer } from "@mail/backend_components/activity_cell_view/activity_cell_view_container"; +import { Component, useState } from "@odoo/owl"; +import { ColumnProgress } from "@web/views/view_components/column_progress"; +import { ActivityRecord } from "./activity_record"; + +export class ActivityRenderer extends Component { + static components = { + ActivityRecord, + ColumnProgress, + ActivityCellViewContainer, + }; + static props = { + activityTypes: { type: Array }, + activityResIds: { type: Array }, + fields: { type: Object }, + records: { type: Array }, + archInfo: { type: Object }, + groupedActivities: { type: Object }, + scheduleActivity: { type: Function }, + onReloadData: { type: Function }, + onEmptyCell: { type: Function }, + onSendMailTemplate: { type: Function }, + openRecord: { type: Function }, + }; + static template = "mail.ActivityRenderer"; + + setup() { + this.activeFilter = useState({ + progressValue: { + active: null, + }, + activityTypeId: null, + resIds: [], + }); + } + + /** + * Gets all activity resIds in the view. + * + * @returns filtered resIds first then the rest. + */ + get activityResIds() { + return [...this.props.activityResIds].sort((a) => this.activeFilter.resIds.includes(a) ? -1 : 0); + } + + getGroupInfo(group) { + const types = { + planned: { + color: "success", + value: 0, + }, + today: { + color: "warning", + value: 0, + }, + overdue: { + value: 0, + color: "danger", + }, + }; + const typeId = group[0]; + const progressValue = this.activeFilter.progressValue; + const isColumnFiltered = this.activeFilter.activityTypeId === group[0]; + + let totalCount = 0; + for (const activities of Object.values(this.props.groupedActivities)) { + if (typeId in activities) { + types[activities[typeId].state].value += 1; + totalCount++; + } + } + + const progressBars = []; + for (const [value, count] of Object.entries(types)) { + progressBars.push({ + count: count.value, + value, + string: this.props.fields.activity_state.selection.find((e) => e[0] === value)[1], + color: count.color, + }); + } + + return { + aggregate: { + title: group[1], + value: isColumnFiltered ? types[progressValue.active].value : totalCount, + }, + data: { + count: totalCount, + filterProgressValue: (name) => this.onSetProgressBarState(typeId, name), + progressBars, + progressValue, + }, + }; + } + + getRecord(resId) { + return this.props.records.find((r) => r.resId === resId); + } + + onSetProgressBarState(typeId, name) { + if (this.activeFilter.progressValue.active === name) { + this.activeFilter.progressValue.active = null; + this.activeFilter.activityTypeId = null; + this.activeFilter.resIds = []; + } else { + this.activeFilter.progressValue.active = name; + this.activeFilter.activityTypeId = typeId; + this.activeFilter.resIds = Object.entries(this.props.groupedActivities) + .filter( + ([, resIds]) => typeId in resIds && + resIds[typeId].state === name + ) + .map(([key]) => parseInt(key)); + } + } +} diff --git a/addons/mail/static/src/views/activity/activity_renderer.xml b/addons/mail/static/src/views/activity/activity_renderer.xml new file mode 100644 index 00000000000..09e8728d769 --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_renderer.xml @@ -0,0 +1,86 @@ + + + + + + + +
+ + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Schedule activity + + + + + +
+ + + + + +
+
+ +
diff --git a/addons/mail/static/src/views/activity/activity_view.js b/addons/mail/static/src/views/activity/activity_view.js new file mode 100644 index 00000000000..e66ad5cd1e8 --- /dev/null +++ b/addons/mail/static/src/views/activity/activity_view.js @@ -0,0 +1,30 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { ActivityController } from "./activity_controller"; +import { ActivityRenderer } from "./activity_renderer"; +import { ActivityModel } from "./activity_model"; +import { ActivityArchParser } from "./activity_arch_parser"; + +export const activityView = { + type: "activity", + display_name: "Activity", + icon: "fa fa-clock-o", + multiRecord: true, + searchMenuTypes: ["filter", "favorite"], + Controller: ActivityController, + Renderer: ActivityRenderer, + ArchParser: ActivityArchParser, + Model: ActivityModel, + props: (genericProps, view) => { + const { arch, relatedModels, resModel } = genericProps; + const archInfo = new view.ArchParser().parse(arch, relatedModels, resModel); + return { + ...genericProps, + archInfo, + Model: view.Model, + Renderer: view.Renderer, + }; + }, +}; +registry.category("views").add("activity", activityView); diff --git a/addons/mail/static/src/xml/activity_view.xml b/addons/mail/static/src/xml/activity_view.xml deleted file mode 100644 index 94da654ab88..00000000000 --- a/addons/mail/static/src/xml/activity_view.xml +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - -
- - - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Schedule activity - - - - - -
- - - - - -
-
- - diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 944c972843e..64031268abb 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -63,7 +63,6 @@ 'project/static/src/services/**/*', 'project/static/src/components/**/*', 'project/static/src/views/**/*', - 'project/static/src/js/project_activity.js', 'project/static/src/js/project_control_panel.js', 'project/static/src/js/project_graph_view.js', 'project/static/src/js/project_pivot_view.js', diff --git a/addons/project/static/src/js/project_activity.js b/addons/project/static/src/js/project_activity.js deleted file mode 100644 index eca5211d155..00000000000 --- a/addons/project/static/src/js/project_activity.js +++ /dev/null @@ -1,13 +0,0 @@ -/** @odoo-module **/ - -import ActivityView from '@mail/js/views/activity/activity_view'; -import { ProjectControlPanel } from '@project/js/project_control_panel'; -import viewRegistry from 'web.view_registry'; - -const ProjectActivityView = ActivityView.extend({ - config: Object.assign({}, ActivityView.prototype.config, { - ControlPanel: ProjectControlPanel, - }), -}); - -viewRegistry.add('project_activity', ProjectActivityView); diff --git a/addons/project/static/src/scss/project_dashboard.scss b/addons/project/static/src/scss/project_dashboard.scss index 427509f0f65..f33057ae61f 100644 --- a/addons/project/static/src/scss/project_dashboard.scss +++ b/addons/project/static/src/scss/project_dashboard.scss @@ -51,7 +51,7 @@ } } - .o_kanban_counter_progress .bg-purple { + .o_column_progress .bg-purple { background-color: purple; } diff --git a/addons/project/static/src/views/project_activity/project_activity_view.js b/addons/project/static/src/views/project_activity/project_activity_view.js new file mode 100644 index 00000000000..5f6cdf574d1 --- /dev/null +++ b/addons/project/static/src/views/project_activity/project_activity_view.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { activityView } from "@mail/views/activity/activity_view"; +import { ProjectControlPanel } from "@project/components/project_control_panel/project_control_panel"; + +export const projectActivityView = { + ...activityView, + ControlPanel: ProjectControlPanel, +}; +registry.category("views").add("project_activity", projectActivityView); diff --git a/addons/test_mail/static/tests/activity_tests.js b/addons/test_mail/static/tests/activity_tests.js index 4b657422428..1bde2ccd4db 100644 --- a/addons/test_mail/static/tests/activity_tests.js +++ b/addons/test_mail/static/tests/activity_tests.js @@ -1,452 +1,587 @@ /** @odoo-module **/ -import ActivityRenderer from '@mail/js/views/activity/activity_renderer'; -import { start, startServer } from '@mail/../tests/helpers/test_utils'; +import { ActivityRenderer } from "@mail/views/activity/activity_renderer"; +import { start, startServer } from "@mail/../tests/helpers/test_utils"; -import testUtils from 'web.test_utils'; +import testUtils from "web.test_utils"; import { legacyExtraNextTick, patchWithCleanup, click } from "@web/../tests/helpers/utils"; import { doAction } from "@web/../tests/webclient/helpers"; -import { session } from '@web/session'; +import { session } from "@web/session"; let serverData; let pyEnv; -QUnit.module('test_mail', {}, function () { -QUnit.module('activity view', { - async beforeEach() { - pyEnv = await startServer(); - const mailTemplateIds = pyEnv['mail.template'].create([{ name: "Template1" }, { name: "Template2" }]); +QUnit.module("test_mail", {}, function () { + QUnit.module("activity view", { + async beforeEach() { + pyEnv = await startServer(); + const mailTemplateIds = pyEnv["mail.template"].create([ + { name: "Template1" }, + { name: "Template2" }, + ]); + // reset incompatible setup + pyEnv["mail.activity.type"].unlink(pyEnv["mail.activity.type"].search([])); + const mailActivityTypeIds = pyEnv["mail.activity.type"].create([ + { name: "Email", mail_template_ids: mailTemplateIds }, + { name: "Call" }, + { name: "Call for Demo" }, + { name: "To Do" }, + ]); + const resUsersId1 = pyEnv["res.users"].create({ display_name: "first user" }); + const mailActivityIds = pyEnv["mail.activity"].create([ + { + display_name: "An activity", + date_deadline: moment().add(3, "days").format("YYYY-MM-DD"), // now + can_write: true, + state: "planned", + activity_type_id: mailActivityTypeIds[0], + mail_template_ids: mailTemplateIds, + user_id: resUsersId1, + }, + { + display_name: "An activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + activity_type_id: mailActivityTypeIds[0], + mail_template_ids: mailTemplateIds, + user_id: resUsersId1, + }, + { + res_model: "mail.test.activity", + display_name: "An activity", + date_deadline: moment().subtract(2, "days").format("YYYY-MM-DD"), // now + can_write: true, + state: "overdue", + activity_type_id: mailActivityTypeIds[1], + user_id: resUsersId1, + }, + ]); + pyEnv["mail.test.activity"].create([ + { name: "Meeting Room Furnitures", activity_ids: [mailActivityIds[0]] }, + { name: "Office planning", activity_ids: [mailActivityIds[1], mailActivityIds[2]] }, + ]); + serverData = { + views: { + "mail.test.activity,false,activity": + '' + + "" + + '
' + + '' + + "
" + + "
" + + "
", + }, + }; + }, + }); + + 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); + const mailTestActivityIds = pyEnv["mail.test.activity"].search([]); + const mailActivityTypeIds = pyEnv["mail.activity.type"].search([]); + + const { env, openView } = await start({ + serverData, + }); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + patchWithCleanup(env.services.action, { + doAction(action, options) { + assert.deepEqual( + action, + { + context: { + default_res_id: mailTestActivityIds[1], + default_res_model: "mail.test.activity", + default_activity_type_id: mailActivityTypeIds[2], + }, + 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" + ); + options.onClose(); + return Promise.resolve(); + }, + }); + + const $activity = $(document.querySelector(".o_activity_view")); + assert.containsOnce($activity, "table", "should have a table"); + var $th1 = $activity.find("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_activity_counter", + "should contain a progressbar in header of first column" + ); + assert.hasAttrValue( + $th1.find(".o_column_progress .progress-bar:first"), + "data-tooltip", + "1 Planned", + "the counter progressbars should be correctly displayed" + ); + assert.hasAttrValue( + $th1.find(".o_column_progress .progress-bar:nth-child(2)"), + "data-tooltip", + "1 Today", + "the counter progressbars should be correctly displayed" + ); + var $th2 = $activity.find("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_column_progress .progress-bar"), + "data-tooltip", + "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.find("table tbody tr:first td:first:contains(Office planning)").length, + 'should contain "Office planning" in first colum of first row' + ); + assert.ok( + $activity.find("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.find( + "table tbody tr:first td:nth-child(2).today .o_ActivityCellView_closestDeadline: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.find(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" + ); + }); + + QUnit.test("activity view: no content rendering", async function (assert) { + assert.expect(2); + + const { openView, pyEnv } = await start({ + serverData, + }); // reset incompatible setup - pyEnv['mail.activity.type'].unlink(pyEnv['mail.activity.type'].search([])); - const mailActivityTypeIds = pyEnv['mail.activity.type'].create([ - { name: "Email", mail_template_ids: mailTemplateIds }, - { name: "Call" }, - { name: "Call for Demo" }, - { name: "To Do" }, - ]); - const resUsersId1 = pyEnv['res.users'].create({ display_name: 'first user' }); - const mailActivityIds = pyEnv['mail.activity'].create([ - { - display_name: "An activity", - date_deadline: moment().add(3, "days").format("YYYY-MM-DD"), // now - can_write: true, - state: "planned", - activity_type_id: mailActivityTypeIds[0], - mail_template_ids: mailTemplateIds, - user_id: resUsersId1, - }, - { - display_name: "An activity", - date_deadline: moment().format("YYYY-MM-DD"), // now - can_write: true, - state: "today", - activity_type_id: mailActivityTypeIds[0], - mail_template_ids: mailTemplateIds, - user_id: resUsersId1, - }, - { - res_model: 'mail.test.activity', - display_name: "An activity", - date_deadline: moment().subtract(2, "days").format("YYYY-MM-DD"), // now - can_write: true, - state: "overdue", - activity_type_id: mailActivityTypeIds[1], - user_id: resUsersId1, + pyEnv["mail.activity.type"].unlink(pyEnv["mail.activity.type"].search([])); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + const $activity = $(document); + + assert.containsOnce($activity, ".o_view_nocontent", "should display the no content helper"); + assert.strictEqual( + $activity.find(".o_view_nocontent .o_view_nocontent_empty_folder").text().trim(), + "No data to display", + "should display the no content helper text" + ); + }); + + QUnit.test("activity view: batch send mail on activity", async function (assert) { + assert.expect(6); + + const mailTestActivityIds = pyEnv["mail.test.activity"].search([]); + const mailTemplateIds = pyEnv["mail.template"].search([]); + const { openView } = await start({ + serverData, + mockRPC: function (route, args) { + if (args.method === "activity_send_mail") { + assert.step(JSON.stringify(args.args)); + return Promise.resolve(true); + } }, + }); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + const $activity = $(document); + assert.notOk( + $activity.find( + "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.find("table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v") + ); + assert.ok( + $activity.find( + "table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show" + ).length, + "dropdown should have appeared" + ); + + testUtils.dom.click( + $activity.find( + "table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template2)" + ) + ); + assert.notOk( + $activity.find( + "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.find("table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v") + ); + testUtils.dom.click( + $activity.find( + "table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template1)" + ) + ); + assert.verifySteps([ + `[[${mailTestActivityIds[0]},${mailTestActivityIds[1]}],${mailTemplateIds[1]}]`, // send mail template 1 on mail.test.activity 1 and 2 + `[[${mailTestActivityIds[0]},${mailTestActivityIds[1]}],${mailTemplateIds[0]}]`, // send mail template 2 on mail.test.activity 1 and 2 ]); - pyEnv['mail.test.activity'].create([ - { name: 'Meeting Room Furnitures', activity_ids: [mailActivityIds[0]] }, - { name: 'Office planning', activity_ids: [mailActivityIds[1], mailActivityIds[2]] }, + }); + + QUnit.test("activity view: activity widget", async function (assert) { + assert.expect(16); + + const mailActivityTypeIds = pyEnv["mail.activity.type"].search([]); + const [mailTestActivityId2] = pyEnv["mail.test.activity"].search([ + ["name", "=", "Office planning"], ]); - serverData = { - views: { - 'mail.test.activity,false,activity': - '' + - '' + - '
' + - '' + - '
' + - '
' + - '
', + const [mailTemplateId1] = pyEnv["mail.template"].search([["name", "=", "Template1"]]); + const { env, openView } = await start({ + mockRPC: function (route, args) { + if (args.method === "activity_send_mail") { + assert.deepEqual( + [[mailTestActivityId2], mailTemplateId1], + args.args, + "Should send template related to mailTestActivity2" + ); + assert.step("activity_send_mail"); + // random value returned in order for the mock server to know that this route is implemented. + return true; + } + if (args.method === "action_feedback_schedule_next") { + assert.deepEqual( + [pyEnv["mail.activity"].search([["state", "=", "overdue"]])], + args.args, + "Should execute action_feedback_schedule_next only on the overude activity" + ); + assert.equal(args.kwargs.feedback, "feedback2"); + assert.step("action_feedback_schedule_next"); + return Promise.resolve({ serverGeneratedAction: true }); + } + }, + serverData, + }); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + patchWithCleanup(env.services.action, { + doAction(action) { + if (action.serverGeneratedAction) { + assert.step("serverGeneratedAction"); + } else if (action.res_model === "mail.compose.message") { + assert.deepEqual( + { + default_model: "mail.test.activity", + default_res_id: mailTestActivityId2, + default_template_id: mailTemplateId1, + default_use_template: true, + force_email: true, + }, + action.context + ); + assert.step("do_action_compose"); + } else if (action.res_model === "mail.activity") { + assert.deepEqual( + { + default_activity_type_id: mailActivityTypeIds[1], + default_res_id: mailTestActivityId2, + default_res_model: "mail.test.activity", + }, + action.context + ); + assert.step("do_action_activity"); + } else { + assert.step("Unexpected action"); + } + return Promise.resolve(); + }, + }); + + await click( + document.querySelector(".today .o_ActivityCellView_closestDeadline") + ); + assert.containsOnce(document.body, ".o_ActivityListView", "dropdown should be displayed"); + assert.ok( + document.querySelector(".o_ActivityListView_todayTitle").textContent.includes("Today"), + "Title should be today" + ); + assert.ok( + [...document.querySelectorAll(".o_MailTemplateView_name")].filter((el) => + el.textContent.includes("Template1") + ).length, + "Template1 should be available" + ); + assert.ok( + [...document.querySelectorAll(".o_MailTemplateView_name")].filter((el) => + el.textContent.includes("Template2") + ).length, + "Template2 should be available" + ); + + await click(document.querySelector(".o_MailTemplateView_preview")); + await click( + document.querySelector(".today .o_ActivityCellView_closestDeadline") + ); + await click(document.querySelector(".o_MailTemplateView_send")); + await click( + document.querySelector(".overdue .o_ActivityCellView_closestDeadline") + ); + assert.containsNone( + document.body, + ".o_MailTemplateView_name", + "No template should be available" + ); + + await click(document.querySelector(".o_ActivityListView_addActivityButton")); + await click( + document.querySelector(".overdue .o_ActivityCellView_closestDeadline") + ); + await click(document.querySelector(".o_ActivityListViewItem_markAsDone")); + document.querySelector(".o_ActivityMarkDonePopoverContentView_feedback").value = + "feedback2"; + + await click( + document.querySelector(".o_ActivityMarkDonePopoverContentView_doneScheduleNextButton") + ); + assert.verifySteps([ + "do_action_compose", + "activity_send_mail", + "do_action_activity", + "action_feedback_schedule_next", + "serverGeneratedAction", + ]); + }); + + QUnit.test("activity view: no group_by_menu and no comparison_menu", async function (assert) { + assert.expect(4); + + serverData.actions = { + 1: { + id: 1, + name: "MailTestActivity Action", + res_model: "mail.test.activity", + type: "ir.actions.act_window", + views: [[false, "activity"]], }, }; - } -}); -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); - const mailTestActivityIds = pyEnv['mail.test.activity'].search([]); - const mailActivityTypeIds = pyEnv['mail.activity.type'].search([]); - - const { env, openView } = await start({ - serverData, - }); - await openView({ - res_model: "mail.test.activity", - views: [[false, "activity"]], - }); - patchWithCleanup(env.services.action, { - doAction(action, options) { - assert.deepEqual(action, { - context: { - default_res_id: mailTestActivityIds[1], - default_res_model: "mail.test.activity", - default_activity_type_id: mailActivityTypeIds[2], - }, - 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"); - options.onClose(); - return Promise.resolve(); - }, - }); - - const $activity = $(document.querySelector('.o_activity_view')); - assert.containsOnce($activity, 'table', - 'should have a table'); - var $th1 = $activity.find('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_legacy_kanban_counter', 'should contain a progressbar in header of first column'); - assert.hasAttrValue($th1.find('.o_kanban_counter_progress .progress-bar:first'), 'data-bs-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-bs-original-title', '1 Today', - 'the counter progressbars should be correctly displayed'); - var $th2 = $activity.find('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-bs-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.find('table tbody tr:first td:first:contains(Office planning)').length, - 'should contain "Office planning" in first colum of first row'); - assert.ok($activity.find('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.find('table tbody tr:first td:nth-child(2).today .o_ActivityCellView_closestDeadline: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.find(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'); -}); - -QUnit.test('activity view: no content rendering', async function (assert) { - assert.expect(2); - - const { openView, pyEnv } = await start({ - serverData, - }); - // reset incompatible setup - pyEnv['mail.activity.type'].unlink(pyEnv['mail.activity.type'].search([])); - await openView({ - res_model: "mail.test.activity", - views: [[false, "activity"]], - }); - const $activity = $(document); - - assert.containsOnce($activity, '.o_view_nocontent', - "should display the no content helper"); - assert.strictEqual($activity.find('.o_view_nocontent .o_view_nocontent_empty_folder').text().trim(), - "No data to display", - "should display the no content helper text"); -}); - -QUnit.test('activity view: batch send mail on activity', async function (assert) { - assert.expect(6); - - const mailTestActivityIds = pyEnv['mail.test.activity'].search([]); - const mailTemplateIds = pyEnv['mail.template'].search([]); - const { openView } = await start({ - serverData, - mockRPC: function(route, args) { - if (args.method === 'activity_send_mail') { - assert.step(JSON.stringify(args.args)); - return Promise.resolve(true); - } - }, - }); - await openView({ - res_model: "mail.test.activity", - views: [[false, "activity"]], - }); - const $activity = $(document); - assert.notOk($activity.find('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.find('table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v')); - assert.ok($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show').length, - 'dropdown should have appeared'); - - testUtils.dom.click($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template2)')); - assert.notOk($activity.find('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.find('table thead tr:first th:nth-child(2) span:nth-child(2) i.fa-ellipsis-v')); - testUtils.dom.click($activity.find('table thead tr:first th:nth-child(2) span:nth-child(2) .dropdown-menu.show .o_send_mail_template:contains(Template1)')); - assert.verifySteps([ - `[[${mailTestActivityIds[0]},${mailTestActivityIds[1]}],${mailTemplateIds[1]}]`, // send mail template 1 on mail.test.activity 1 and 2 - `[[${mailTestActivityIds[0]},${mailTestActivityIds[1]}],${mailTemplateIds[0]}]`, // send mail template 2 on mail.test.activity 1 and 2 - ]); -}); - -QUnit.test('activity view: activity widget', async function (assert) { - assert.expect(16); - - const mailActivityTypeIds = pyEnv['mail.activity.type'].search([]); - const [mailTestActivityId2] = pyEnv['mail.test.activity'].search([['name', '=', 'Office planning']]); - const [mailTemplateId1] = pyEnv['mail.template'].search([['name', '=', 'Template1']]); - const { env, openView } = await start({ - mockRPC: function (route, args) { - if (args.method === 'activity_send_mail') { - assert.deepEqual([[mailTestActivityId2], mailTemplateId1], args.args, "Should send template related to mailTestActivity2"); - assert.step('activity_send_mail'); - // random value returned in order for the mock server to know that this route is implemented. - return true; - } - if (args.method === 'action_feedback_schedule_next') { - assert.deepEqual( - [pyEnv['mail.activity'].search([['state', '=', 'overdue']])], - args.args, - "Should execute action_feedback_schedule_next only on the overude activity" + const mockRPC = (route, args) => { + if (args.method === "get_activity_data") { + assert.strictEqual( + args.kwargs.context.lang, + "zz_ZZ", + "The context should have been passed" ); - assert.equal(args.kwargs.feedback, "feedback2"); - assert.step('action_feedback_schedule_next'); - return Promise.resolve({ serverGeneratedAction: true }); } - }, - serverData, - }); - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'activity']], - }); - patchWithCleanup(env.services.action, { - doAction(action) { - if (action.serverGeneratedAction) { - assert.step('serverGeneratedAction'); - } else if (action.res_model === 'mail.compose.message') { - assert.deepEqual({ - default_model: 'mail.test.activity', - default_res_id: mailTestActivityId2, - default_template_id: mailTemplateId1, - default_use_template: true, - force_email: true - }, action.context); - assert.step("do_action_compose"); - } else if (action.res_model === 'mail.activity') { - assert.deepEqual({ - "default_activity_type_id": mailActivityTypeIds[1], - "default_res_id": mailTestActivityId2, - "default_res_model": 'mail.test.activity', - }, action.context); - assert.step("do_action_activity"); - } else { - assert.step("Unexpected action"); - } - return Promise.resolve(); - }, + }; + + patchWithCleanup(session.user_context, { lang: "zz_ZZ" }); + + const { webClient } = await start({ serverData, mockRPC }); + + await doAction(webClient, 1); + + assert.containsN( + document.body, + ".o_search_options .dropdown button:visible", + 2, + "only two elements should be available in view search" + ); + assert.isVisible( + document.querySelector(".o_search_options .dropdown.o_filter_menu > button"), + "filter should be available in view search" + ); + assert.isVisible( + document.querySelector(".o_search_options .dropdown.o_favorite_menu > button"), + "favorites should be available in view search" + ); }); - await testUtils.dom.click(document.querySelector('.today .o_ActivityCellView_closestDeadline')); - assert.containsOnce(document.body, '.o_ActivityListView', "dropdown should be displayed"); - assert.ok(document.querySelector('.o_ActivityListView_todayTitle').textContent.includes('Today'), "Title should be today"); - assert.ok([...document.querySelectorAll('.o_MailTemplateView_name')].filter(el => el.textContent.includes('Template1')).length, - "Template1 should be available"); - assert.ok([...document.querySelectorAll('.o_MailTemplateView_name')].filter(el => el.textContent.includes('Template2')).length, - "Template2 should be available"); - - await testUtils.dom.click(document.querySelector('.o_MailTemplateView_preview')); - await testUtils.dom.click(document.querySelector('.today .o_ActivityCellView_closestDeadline')); - await testUtils.dom.click(document.querySelector('.o_MailTemplateView_send')); - await testUtils.dom.click(document.querySelector('.overdue .o_ActivityCellView_closestDeadline')); - assert.containsNone(document.body, '.o_MailTemplateView_name', - "No template should be available"); - - await testUtils.dom.click(document.querySelector('.o_ActivityListView_addActivityButton')); - await testUtils.dom.click(document.querySelector('.overdue .o_ActivityCellView_closestDeadline')); - await testUtils.dom.click(document.querySelector('.o_ActivityListViewItem_markAsDone')); - document.querySelector('.o_ActivityMarkDonePopoverContentView_feedback').value = "feedback2"; - - await testUtils.dom.click(document.querySelector('.o_ActivityMarkDonePopoverContentView_doneScheduleNextButton')); - assert.verifySteps([ - "do_action_compose", - "activity_send_mail", - "do_action_activity", - "action_feedback_schedule_next", - "serverGeneratedAction" - ]); - -}); - -QUnit.test("activity view: no group_by_menu and no comparison_menu", async function (assert) { - assert.expect(4); - - serverData.actions = { - 1: { - id: 1, - name: "MailTestActivity Action", - res_model: "mail.test.activity", - type: "ir.actions.act_window", - views: [[false, "activity"]], - }, - }; - - const mockRPC = (route, args) => { - if (args.method === "get_activity_data") { - assert.strictEqual( - args.kwargs.context.lang, - "zz_ZZ", - "The context should have been passed" - ); - } - }; - - patchWithCleanup(session.user_context, { lang: "zz_ZZ" }); - - const { webClient } = await start({ serverData, mockRPC }); - - await doAction(webClient, 1); - - assert.containsN( - document.body, - ".o_search_options .dropdown button:visible", - 2, - "only two elements should be available in view search" - ); - assert.isVisible( - document.querySelector(".o_search_options .dropdown.o_filter_menu > button"), - "filter should be available in view search" - ); - assert.isVisible( - document.querySelector(".o_search_options .dropdown.o_favorite_menu > button"), - "favorites should be available in view search" - ); -}); - -QUnit.test('activity view: search more to schedule an activity for a record of a respecting model', async function (assert) { - assert.expect(5); - const mailTestActivityId1 = pyEnv['mail.test.activity'].create({ name: 'MailTestActivity 3' }); - Object.assign(serverData.views, { - 'mail.test.activity,false,list': '', - }); - const { env, openView } = await start({ - mockRPC(route, args) { - if (args.method === 'name_search') { - args.kwargs.name = "MailTestActivity"; - } - }, - serverData, - }); - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'activity']], - }); - patchWithCleanup(env.services.action, { - doAction(action, options) { - assert.step('doAction'); - var expectedAction = { - context: { - default_res_id: mailTestActivityId1, - default_res_model: "mail.test.activity", + QUnit.test( + "activity view: search more to schedule an activity for a record of a respecting model", + async function (assert) { + assert.expect(5); + const mailTestActivityId1 = pyEnv["mail.test.activity"].create({ + name: "MailTestActivity 3", + }); + Object.assign(serverData.views, { + "mail.test.activity,false,list": + '', + }); + const { env, openView } = await start({ + mockRPC(route, args) { + if (args.method === "name_search") { + args.kwargs.name = "MailTestActivity"; + } }, - name: "Schedule Activity", - res_id: false, - res_model: "mail.activity", - target: "new", + serverData, + }); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + patchWithCleanup(env.services.action, { + doAction(action, options) { + assert.step("doAction"); + var expectedAction = { + context: { + default_res_id: mailTestActivityId1, + default_res_model: "mail.test.activity", + }, + 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( + action, + expectedAction, + "should execute an action with correct params" + ); + options.onClose(); + return Promise.resolve(); + }, + }); + + const activity = $(document); + 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.find("table tfoot tr .o_record_selector")); + // search create dialog + var $modal = $(".modal-lg"); + assert.strictEqual( + $modal.find(".o_data_row").length, + 3, + "all mail.test.activity should be available to select" + ); + // select a record to schedule an activity for it (this triggers a do_action) + await testUtils.dom.click($modal.find(".o_data_row:last .o_data_cell")); + assert.verifySteps(["doAction"]); + } + ); + + QUnit.test("Activity view: discard an activity creation dialog", async function (assert) { + assert.expect(2); + + serverData.actions = { + 1: { + id: 1, + name: "MailTestActivity Action", + res_model: "mail.test.activity", type: "ir.actions.act_window", - view_mode: "form", - views: [[false, "form"]], - }; - assert.deepEqual(action, expectedAction, - "should execute an action with correct params"); - options.onClose(); - return Promise.resolve(); - }, - }); + views: [[false, "activity"]], + }, + }; - const activity = $(document); - 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.find('table tfoot tr .o_record_selector')); - // search create dialog - var $modal = $('.modal-lg'); - assert.strictEqual($modal.find('.o_data_row').length, 3, "all mail.test.activity should be available to select"); - // select a record to schedule an activity for it (this triggers a do_action) - await testUtils.dom.click($modal.find('.o_data_row:last .o_data_cell')); - assert.verifySteps(['doAction']); -}); - -QUnit.test("Activity view: discard an activity creation dialog", async function (assert) { - assert.expect(2); - - serverData.actions = { - 1: { - id: 1, - name: "MailTestActivity Action", - res_model: "mail.test.activity", - type: "ir.actions.act_window", - views: [[false, "activity"]], - }, - }; - - Object.assign(serverData.views, { - 'mail.activity,false,form': - `
+ Object.assign(serverData.views, { + "mail.activity,false,form": `
`, + }); + + const mockRPC = (route, args) => { + if (args.method === "check_access_rights") { + return true; + } + }; + + const { webClient } = await start({ serverData, mockRPC }); + await doAction(webClient, 1); + + await testUtils.dom.click( + document.querySelector(".o_activity_view .o_data_row .o_activity_empty_cell") + ); + await legacyExtraNextTick(); + assert.containsOnce($, ".modal.o_technical_modal", "Activity Modal should be opened"); + + await testUtils.dom.click($('.modal.o_technical_modal button[special="cancel"]')); + await legacyExtraNextTick(); + assert.containsNone($, ".modal.o_technical_modal", "Activity Modal should be closed"); }); - const mockRPC = (route, args) => { - if (args.method === "check_access_rights") { - return true; - } - }; + QUnit.test( + "Activity view: many2one_avatar_user widget in activity view", + async function (assert) { + assert.expect(3); - const { webClient } = await start({ serverData, mockRPC }); - await doAction(webClient, 1); - - await testUtils.dom.click( - document.querySelector(".o_activity_view .o_data_row .o_activity_empty_cell") - ); - await legacyExtraNextTick(); - assert.containsOnce($, ".modal.o_technical_modal", "Activity Modal should be opened"); - - await testUtils.dom.click($('.modal.o_technical_modal button[special="cancel"]')); - await legacyExtraNextTick(); - assert.containsNone($, ".modal.o_technical_modal", "Activity Modal should be closed"); -}); - -QUnit.test('Activity view: many2one_avatar_user widget in activity view', async function (assert) { - assert.expect(3); - - const [mailTestActivityId1] = pyEnv['mail.test.activity'].search([['name', '=', 'Meeting Room Furnitures']]); - const resUsersId1 = pyEnv['res.users'].create({ - display_name: "first user", - avatar_128: "Atmaram Bhide", - }); - pyEnv['mail.test.activity'].write([mailTestActivityId1], { activity_user_id: resUsersId1 }); - Object.assign(serverData.views, { - 'mail.test.activity,false,activity': - ` + const [mailTestActivityId1] = pyEnv["mail.test.activity"].search([ + ["name", "=", "Meeting Room Furnitures"], + ]); + const resUsersId1 = pyEnv["res.users"].create({ + display_name: "first user", + avatar_128: "Atmaram Bhide", + }); + pyEnv["mail.test.activity"].write([mailTestActivityId1], { + activity_user_id: resUsersId1, + }); + Object.assign(serverData.views, { + "mail.test.activity,false,activity": `
@@ -454,159 +589,187 @@ QUnit.test('Activity view: many2one_avatar_user widget in activity view', async
`, - }); - serverData.actions = { - 1: { - id: 1, - name: 'MailTestActivity Action', - res_model: 'mail.test.activity', - type: 'ir.actions.act_window', - views: [[false, 'activity']], - } - }; - - const { webClient } = await start({ serverData }); - await doAction(webClient, 1); - - await legacyExtraNextTick(); - assert.containsN(document.body, '.o_m2o_avatar', 2); - assert.containsOnce(document.body, `tr[data-res-id=${mailTestActivityId1}] .o_m2o_avatar > img[data-src="/web/image/res.users/${resUsersId1}/avatar_128"]`, - "should have m2o avatar image"); - assert.containsNone(document.body, '.o_m2o_avatar > span', - "should not have text on many2one_avatar_user if onlyImage node option is passed"); -}); - -QUnit.test("Activity view: on_destroy_callback doesn't crash", async function (assert) { - assert.expect(3); - - patchWithCleanup(ActivityRenderer.prototype, { - setup() { - this._super(); - owl.onMounted(() => { - assert.step('mounted'); }); - owl.onWillUnmount(() => { - assert.step('willUnmount'); + serverData.actions = { + 1: { + id: 1, + name: "MailTestActivity Action", + res_model: "mail.test.activity", + type: "ir.actions.act_window", + views: [[false, "activity"]], + }, + }; + + const { webClient } = await start({ serverData }); + await doAction(webClient, 1); + + await legacyExtraNextTick(); + assert.containsN(document.body, ".o_m2o_avatar", 2); + assert.containsOnce( + document.body, + `tr:nth-child(2) .o_m2o_avatar > img[data-src="/web/image/res.users/${resUsersId1}/avatar_128"]`, + "should have m2o avatar image" + ); + assert.containsNone( + document.body, + ".o_m2o_avatar > span", + "should not have text on many2one_avatar_user if onlyImage node option is passed" + ); + } + ); + + QUnit.test("Activity view: on_destroy_callback doesn't crash", async function (assert) { + assert.expect(3); + + patchWithCleanup(ActivityRenderer.prototype, { + setup() { + this._super(); + owl.onMounted(() => { + assert.step("mounted"); + }); + owl.onWillUnmount(() => { + assert.step("willUnmount"); + }); + }, + }); + + const { openView } = await start({ + serverData, + }); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + }); + // force the unmounting of the activity view by opening another one + await openView({ + res_model: "mail.test.activity", + views: [[false, "form"]], + }); + + assert.verifySteps(["mounted", "willUnmount"]); + }); + + QUnit.test( + "Schedule activity dialog uses the same search view as activity view", + async function (assert) { + assert.expect(8); + pyEnv["mail.test.activity"].unlink(pyEnv["mail.test.activity"].search([])); + Object.assign(serverData.views, { + "mail.test.activity,false,list": ``, + "mail.test.activity,false,search": ``, + "mail.test.activity,1,search": ``, }); + + function mockRPC(route, args) { + if (args.method === "get_views") { + assert.step(JSON.stringify(args.kwargs.views)); + } + } + + const { webClient } = await start({ serverData, mockRPC }); + + // open an activity view (with default search arch) + await doAction(webClient, { + name: "Dashboard", + res_model: "mail.test.activity", + type: "ir.actions.act_window", + views: [[false, "activity"]], + }); + + assert.verifySteps(['[[false,"activity"],[false,"search"]]']); + + // click on "Schedule activity" + await click(document.querySelector(".o_activity_view .o_record_selector")); + + assert.verifySteps(['[[false,"list"],[false,"search"]]']); + + // open an activity view (with search arch 1) + await doAction(webClient, { + name: "Dashboard", + res_model: "mail.test.activity", + type: "ir.actions.act_window", + views: [[false, "activity"]], + search_view_id: [1, "search"], + }); + + assert.verifySteps(['[[false,"activity"],[1,"search"]]']); + + // click on "Schedule activity" + await click(document.querySelector(".o_activity_view .o_record_selector")); + + assert.verifySteps(['[[false,"list"],[1,"search"]]']); } - }); + ); - const { openView } = await start({ - serverData, - }); - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'activity']], - }); - // force the unmounting of the activity view by opening another one - await openView({ - res_model: 'mail.test.activity', - views: [[false, 'form']], - }); - - assert.verifySteps([ - 'mounted', - 'willUnmount' - ]); -}); - -QUnit.test("Schedule activity dialog uses the same search view as activity view", async function (assert) { - assert.expect(8); - pyEnv['mail.test.activity'].unlink(pyEnv['mail.test.activity'].search([])); - Object.assign(serverData.views, { - "mail.test.activity,false,list": ``, - "mail.test.activity,false,search": ``, - 'mail.test.activity,1,search': ``, - }); - - function mockRPC(route, args) { - if (args.method === "get_views") { - assert.step(JSON.stringify(args.kwargs.views)); - } - } - - const { webClient } = await start({ serverData, mockRPC }); - - // open an activity view (with default search arch) - await doAction(webClient, { - name: 'Dashboard', - res_model: 'mail.test.activity', - type: 'ir.actions.act_window', - views: [[false, 'activity']], - }); - - assert.verifySteps([ - '[[false,"activity"],[false,"search"]]', - ]) - - // click on "Schedule activity" - await click(document.querySelector(".o_activity_view .o_record_selector")); - - assert.verifySteps([ - '[[false,"list"],[false,"search"]]', - ]) - - // open an activity view (with search arch 1) - await doAction(webClient, { - name: 'Dashboard', - res_model: 'mail.test.activity', - type: 'ir.actions.act_window', - views: [[false, 'activity']], - search_view_id: [1,"search"], - }); - - assert.verifySteps([ - '[[false,"activity"],[1,"search"]]', - ]) - - // click on "Schedule activity" - await click(document.querySelector(".o_activity_view .o_record_selector")); - - assert.verifySteps([ - '[[false,"list"],[1,"search"]]', - ]); -}); - -QUnit.test('Activity view: apply progressbar filter', async function (assert) { - assert.expect(9); - - serverData.actions = { - 1: { - id: 1, - name: 'MailTestActivity Action', - res_model: 'mail.test.activity', - type: 'ir.actions.act_window', - views: [[false, 'activity']], - } - }; - - const { webClient } = await start({ serverData }); - - await doAction(webClient, 1); - - assert.containsNone(document.querySelector('.o_activity_view thead'), - '.o_activity_filter_planned,.o_activity_filter_today,.o_activity_filter_overdue,.o_activity_filter___false', - "should not have active filter"); - assert.containsNone(document.querySelector('.o_activity_view tbody'), - '.o_activity_filter_planned,.o_activity_filter_today,.o_activity_filter_overdue,.o_activity_filter___false', - "should not have active filter"); - assert.strictEqual(document.querySelector('.o_activity_view tbody .o_activity_record').textContent, - 'Office planning', "'Office planning' should be first record"); - assert.containsOnce(document.querySelector('.o_activity_view tbody'), '.planned', - "other records should be available"); - - await testUtils.dom.click(document.querySelector('.o_kanban_counter_progress .progress-bar[data-filter="planned"]')); - assert.containsOnce(document.querySelector('.o_activity_view thead'), '.o_activity_filter_planned', - "planned should be active filter"); - assert.containsN(document.querySelector('.o_activity_view tbody'), '.o_activity_filter_planned', 5, - "planned should be active filter"); - assert.strictEqual(document.querySelector('.o_activity_view tbody .o_activity_record').textContent, - 'Meeting Room Furnitures', "'Office planning' should be first record"); - const tr = document.querySelectorAll('.o_activity_view tbody tr')[1]; - assert.hasClass(tr.querySelectorAll('td')[1], 'o_activity_empty_cell', - "other records should be hidden"); - assert.containsNone(document.querySelector('.o_activity_view tbody'), 'planned', - "other records should be hidden"); -}); + QUnit.test("Activity view: apply progressbar filter", async function (assert) { + assert.expect(9); + serverData.actions = { + 1: { + id: 1, + name: "MailTestActivity Action", + res_model: "mail.test.activity", + type: "ir.actions.act_window", + views: [[false, "activity"]], + }, + }; + + const { webClient } = await start({ serverData }); + + await doAction(webClient, 1); + + assert.containsNone( + document.querySelector(".o_activity_view thead"), + ".o_activity_filter_planned,.o_activity_filter_today,.o_activity_filter_overdue,.o_activity_filter___false", + "should not have active filter" + ); + assert.containsNone( + document.querySelector(".o_activity_view tbody"), + ".o_activity_filter_planned,.o_activity_filter_today,.o_activity_filter_overdue,.o_activity_filter___false", + "should not have active filter" + ); + assert.strictEqual( + document.querySelector(".o_activity_view tbody .o_activity_record").textContent, + "Office planning", + "'Office planning' should be first record" + ); + assert.containsOnce( + document.querySelector(".o_activity_view tbody"), + ".planned", + "other records should be available" + ); + + await click( + document.querySelector( + '.o_column_progress .progress-bar' + ) + ); + assert.containsOnce( + document.querySelector(".o_activity_view thead"), + ".o_activity_filter_planned", + "planned should be active filter" + ); + assert.containsN( + document.querySelector(".o_activity_view tbody"), + ".o_activity_filter_planned", + 5, + "planned should be active filter" + ); + assert.strictEqual( + document.querySelector(".o_activity_view tbody .o_activity_record").textContent, + "Meeting Room Furnitures", + "'Office planning' should be first record" + ); + const tr = document.querySelectorAll(".o_activity_view tbody tr")[1]; + assert.hasClass( + tr.querySelectorAll("td")[1], + "o_activity_empty_cell", + "other records should be hidden" + ); + assert.containsNone( + document.querySelector(".o_activity_view tbody"), + "planned", + "other records should be hidden" + ); + }); }); diff --git a/addons/web/static/src/views/basic_relational_model.js b/addons/web/static/src/views/basic_relational_model.js index 641519a1113..9ee14c7d5fc 100644 --- a/addons/web/static/src/views/basic_relational_model.js +++ b/addons/web/static/src/views/basic_relational_model.js @@ -8,6 +8,7 @@ import { KeepLast } from "@web/core/utils/concurrency"; import { escape } from "@web/core/utils/strings"; import { mapDoActionOptionAPI } from "@web/legacy/backend_utils"; import { Model } from "@web/views/model"; +import { getRawValue, getValue } from "@web/views/relational_model"; import { evalDomain } from "@web/views/utils"; import { mapWowlValueToLegacy, @@ -171,6 +172,17 @@ export class Record extends DataPoint { return this.translatableFields.filter((f) => this.dirtyFields.includes(f)); } + get formattedRecord() { + const record = Object.create(this, Object.getOwnPropertyDescriptors(this)); + for (const fieldName in this.activeFields) { + record[fieldName] = { + value: getValue(this, fieldName), + raw_value: getRawValue(this, fieldName), + }; + } + return record; + } + get isInEdition() { return this.mode === "edit"; } diff --git a/addons/web/static/src/views/kanban/kanban_record.js b/addons/web/static/src/views/kanban/kanban_record.js index c144cb04c76..7f845060db9 100644 --- a/addons/web/static/src/views/kanban/kanban_record.js +++ b/addons/web/static/src/views/kanban/kanban_record.js @@ -4,7 +4,6 @@ import { ColorList } from "@web/core/colorlist/colorlist"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { Dropdown } from "@web/core/dropdown/dropdown"; import { DropdownItem } from "@web/core/dropdown/dropdown_item"; -import { registry } from "@web/core/registry"; import { useTooltip } from "@web/core/tooltip/tooltip_hook"; import { useService } from "@web/core/utils/hooks"; import { sprintf } from "@web/core/utils/strings"; @@ -23,8 +22,6 @@ import { KanbanDropdownMenuWrapper } from "./kanban_dropdown_menu_wrapper"; import { Component, onMounted, onWillUpdateProps, useRef } from "@odoo/owl"; const { COLORS } = ColorList; -const formatters = registry.category("formatters"); - // These classes determine whether a click on a record should open it. export const CANCEL_GLOBAL_CLICK = ["a", ".dropdown", ".oe_kanban_action"].join(","); const ALLOW_GLOBAL_CLICK = [".oe_kanban_global_click", ".oe_kanban_global_click_edit"].join(","); @@ -92,48 +89,6 @@ export function getImageSrcFromRecordInfo(record, model, field, idOrIds, placeho } } -/** - * Returns a "raw" version of the field value on a given record. - * - * @param {Record} record - * @param {string} fieldName - * @returns {any} - */ -export function getRawValue(record, fieldName) { - const field = record.fields[fieldName]; - const value = record.data[fieldName]; - switch (field.type) { - case "one2many": - case "many2many": { - return value.count ? value.currentIds : []; - } - case "many2one": { - return (value && value[0]) || false; - } - case "date": - case "datetime": { - return value && value.toISO(); - } - default: { - return value; - } - } -} - -/** - * Returns a formatted version of the field value on a given record. - * - * @param {Record} record - * @param {string} fieldName - * @returns {string} - */ -export function getValue(record, fieldName) { - const field = record.fields[fieldName]; - const value = record.data[fieldName]; - const formatter = formatters.get(field.type, String); - return formatter(value, { field, data: record.data }); -} - function isBinSize(value) { return /^\d+(\.\d*)? [^0-9]+$/.test(value); } @@ -169,7 +124,7 @@ export class KanbanRecord extends Component { if (KANBAN_TOOLTIP_ATTRIBUTE in templates) { useTooltip("root", { - info: this, + info: { ...this, record: this.props.record.formattedRecord }, template: this.templates[KANBAN_TOOLTIP_ATTRIBUTE], }); } @@ -189,22 +144,10 @@ export class KanbanRecord extends Component { * @param {Object} props */ createRecordAndWidget(props) { - const { archInfo, list, record } = props; + const { archInfo, list } = props; const { activeActions } = archInfo; - // Record - this.record = Object.create(null); - for (const fieldName in record.data) { - this.record[fieldName] = { - get value() { - return getValue(record, fieldName); - }, - get raw_value() { - return getRawValue(record, fieldName); - }, - }; - } - + this.record = this.props.record.formattedRecord; // Widget const deletable = activeActions.delete && (!list.groupedBy || !list.groupedBy("m2m")); const editable = activeActions.edit; diff --git a/addons/web/static/src/views/relational_model.js b/addons/web/static/src/views/relational_model.js index cc300ae1440..d8fd95c6ace 100644 --- a/addons/web/static/src/views/relational_model.js +++ b/addons/web/static/src/views/relational_model.js @@ -26,6 +26,7 @@ import { archParseBoolean, evalDomain, isNumeric, isRelational, isX2Many } from const { DateTime } = luxon; import { markRaw, markup, toRaw } from "@odoo/owl"; +const formatters = registry.category("formatters"); const preloadedDataRegistry = registry.category("preloadedData"); const { CREATE, UPDATE, DELETE, FORGET, LINK_TO, DELETE_ALL, REPLACE_WITH } = x2ManyCommands; @@ -396,6 +397,48 @@ function clearObject(obj) { } } +/** + * Returns a "raw" version of the field value on a given record. + * + * @param {Record} record + * @param {string} fieldName + * @returns {any} + */ +export function getRawValue(record, fieldName) { + const field = record.fields[fieldName]; + const value = record.data[fieldName]; + switch (field.type) { + case "one2many": + case "many2many": { + return value.count ? value.currentIds : []; + } + case "many2one": { + return (value && value[0]) || false; + } + case "date": + case "datetime": { + return value && value.toISO(); + } + default: { + return value; + } + } +} + +/** + * Returns a formatted version of the field value on a given record. + * + * @param {Record} record + * @param {string} fieldName + * @returns {string} + */ +export function getValue(record, fieldName) { + const field = record.fields[fieldName]; + const value = record.data[fieldName]; + const formatter = formatters.get(field.type, String); + return formatter(value, { field, data: record.data }); +} + export class Record extends DataPoint { setup(params, state) { if ("resId" in params) { @@ -532,6 +575,17 @@ export class Record extends DataPoint { return this._changes.map((change) => this.activeFields[change]); } + get formattedRecord() { + const record = Object.create(this, Object.getOwnPropertyDescriptors(this)); + for (const fieldName in this.activeFields) { + record[fieldName] = { + value: getValue(this, fieldName), + raw_value: getRawValue(this, fieldName), + }; + } + return record; + } + get isInEdition() { return this.mode === "edit"; } diff --git a/addons/web/static/src/views/view_compiler.js b/addons/web/static/src/views/view_compiler.js index a6d2baa0ad2..2d7f4a12741 100644 --- a/addons/web/static/src/views/view_compiler.js +++ b/addons/web/static/src/views/view_compiler.js @@ -323,7 +323,7 @@ export class ViewCompiler { } const button = createElement("ViewButton", { tag: toStringExpression(tag), - record: `this.props.record`, + record: "this.props.record", }); assignOwlDirectives(button, el); @@ -375,14 +375,14 @@ export class ViewCompiler { * @param {Element} el * @returns {Element} */ - compileField(el) { + compileField(el, params) { const fieldName = el.getAttribute("name"); const fieldId = el.getAttribute("field_id") || fieldName; const field = createElement("Field"); field.setAttribute("id", `'${fieldId}'`); field.setAttribute("name", `'${fieldName}'`); - field.setAttribute("record", `this.props.record`); + field.setAttribute("record", params.recordExpr || "this.props.record"); field.setAttribute("fieldInfo", `this.props.archInfo.fieldNodes['${fieldId}']`); if (el.hasAttribute("widget")) { @@ -422,7 +422,7 @@ export class ViewCompiler { */ compileWidget(el) { const attrs = {}; - const props = { record: `this.props.record`, readonly: this.ctx.readonly }; + const props = { record: "this.props.record", readonly: this.ctx.readonly }; for (const { name, value } of el.attributes) { switch (name) { case "class":