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":