[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) <aab@odoo.com>
Co-authored-by: Florent Dardenne <dafl@odoo.com>
This commit is contained in:
luvi
2022-12-23 16:07:06 +01:00
co-authored by Florent Dardenne
parent 28927c2eeb
commit 97cc94269d
26 changed files with 1390 additions and 1337 deletions
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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,
};
}
}
@@ -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 <field> 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",
];
@@ -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),
};
}
}
@@ -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 <td> 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 {
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ActivityController" owl="1">
<div t-att-class="props.className">
<Layout display="props.display">
<t t-component="props.Renderer" t-props="rendererProps" />
</Layout>
</div>
</t>
</templates>
@@ -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,
});
}
}
@@ -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,
};
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ActivityRecord" owl="1">
<td class="o_activity_record p-3 cursor-pointer" t-on-click="() => this.props.openRecord(this.props.record)">
<t t-call="{{ recordTemplate }}" t-call-context="getRenderingContext()"/>
</td>
</t>
</templates>
@@ -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));
}
}
}
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ActivityViewHeader" owl="1">
<thead>
<tr>
<th></th>
<th t-foreach="props.activityTypes" t-as="type" t-key="type[0]"
class="o_activity_type_cell p-3" t-attf-class="{{ activeFilter.activityTypeId === type[0] ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}"
t-attf-width="{{100/props.activityTypes.length}}%">
<div>
<span t-esc="type[1]"/>
<span t-if="type[2].length > 0" class="dropdown float-end">
<i class="fa fa-ellipsis-v fa-fw" data-bs-toggle="dropdown"/>
<div class="dropdown-menu">
<t t-foreach="type[2]" t-as="template" t-key="template.id">
<div data-tooltip="This action will send an email."
class="o_template_element o_send_mail_template cursor-pointer"
t-on-click="() => props.onSendMailTemplate(template.id, type[0])">
<i class="fa fa-envelope fa-fw"/> <t t-esc="template.name"/>
</div>
</t>
</div>
</span>
</div>
<div class="o_activity_counter d-flex align-items-center justify-content-between mb-3 h-0 mt24">
<t t-set="group" t-value="getGroupInfo(type)"/>
<ColumnProgress t-if="group.data.count" group="group.data" aggregate="group.aggregate" />
</div>
</th>
</tr>
</thead>
</t>
<t t-name="mail.ActivityViewBody" owl="1">
<tbody>
<t t-foreach="activityResIds" t-as="resId" t-key="resId">
<t t-call="mail.ActivityViewRow"/>
</t>
</tbody>
</t>
<t t-name="mail.ActivityViewRow" owl="1">
<tr class="o_data_row h-100" t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}">
<t t-set="record" t-value="getRecord(resId)"/>
<ActivityRecord archInfo="props.archInfo" record="record" openRecord="props.openRecord" />
<t t-foreach="props.activityTypes" t-as="type" t-key="type[0]">
<t t-call="mail.ActivityViewCell"/>
</t>
</tr>
</t>
<t t-name="mail.ActivityViewCell" owl="1">
<t t-set="activityGroup" t-value="props.groupedActivities[resId] and props.groupedActivities[resId][type[0]] or {count: 0, ids: [], state: false}"/>
<t t-set="isCellHidden" t-value="activeFilter.resIds.length and !activeFilter.resIds.includes(resId) and activeFilter.activityTypeId === type[0]"/>
<td t-if="activityGroup.state and !isCellHidden"
t-attf-class="o_activity_summary_cell p-0 h-100 {{activityGroup.state}} {{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}">
<ActivityCellViewContainer activityIds="activityGroup.ids" activityTypeId="type[0]" closestDeadline="activityGroup.o_closest_deadline" reloadFunc="props.onReloadData" resId="record.resId" resModel="record.resModel"/>
</td>
<td t-else=""
class="o_activity_summary_cell o_activity_empty_cell cursor-pointer"
t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}"
t-on-click.prevent.stop="() => props.onEmptyCell(resId, type[0])">
<i data-tooltip="Create" class="text-center fa fa-plus mt-2 align-items-center justify-content-center h-100"/>
</td>
</t>
<t t-name="mail.ActivityViewFooter" owl="1">
<tfoot>
<tr class="o_data_row">
<td class="o_record_selector p-3 cursor-pointer" t-on-click.prevent.stop="props.scheduleActivity">
<span class="btn btn-link"><i class="fa fa-plus pe-2"/><span>Schedule activity</span></span>
</td>
</tr>
</tfoot>
</t>
<div t-name="mail.ActivityRenderer" class="o_activity_view h-100" owl="1">
<t t-if="!props.activityTypes.length" t-call="web.NoContentHelper"/>
<table t-else="" class="table table-bordered mb-5 bg-view o_activity_view_table">
<t t-call="mail.ActivityViewHeader"/>
<t t-call="mail.ActivityViewBody"/>
<t t-call="mail.ActivityViewFooter"/>
</table>
</div>
</templates>
@@ -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);
@@ -1,93 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.ActivityViewHeader" owl="1">
<thead>
<tr>
<th></th>
<th t-foreach="props.activity_types" t-as="type" t-key="type[0]"
class="o_activity_type_cell" t-attf-class="{{ activeFilter.activityTypeId === type[0] ? 'o_activity_filter_' + activeFilter.state.value : '' }}"
t-att-data-activity-type-id="type[0]" t-attf-width="{{100/props.activity_types.length}}%">
<div>
<span t-esc="type[1]"/>
<span t-if="type[2].length > 0" class="dropdown float-end">
<i class="fa fa-ellipsis-v fa-fw" data-bs-toggle="dropdown"/>
<div class="dropdown-menu">
<t t-foreach="type[2]" t-as="template" t-key="template.id">
<div title="This action will send an email."
class="o_template_element o_send_mail_template cursor-pointer"
t-att-data-activity-type-id="type[0]"
t-att-data-template-id="template.id"
t-on-click="_onSendMailTemplateClicked">
<i class="fa fa-envelope fa-fw"/> <t t-esc="template.name"/>
</div>
</t>
</div>
</span>
</div>
<KanbanColumnProgressBarAdapter Component="widgetComponents.KanbanColumnProgressBar"
widgetArgs="[getProgressBarOptions(type[0]), getProgressBarColumnState(type[0])]"
t-if="activityTypeIds.includes(type[0])"
onSetProgressBarState.bind="_onSetProgressBarState"/>
<div t-else="" class="mt24"/>
</th>
</tr>
</thead>
</t>
<t t-name="mail.ActivityViewBody" owl="1">
<tbody>
<t t-foreach="activityResIds" t-as="resId" t-key="resId">
<t t-call="mail.ActivityViewRow"/>
</t>
</tbody>
</t>
<t t-name="mail.ActivityViewRow" owl="1">
<tr class="o_data_row h-100" t-att-data-res-id="resId">
<t t-set="record" t-value="props.data.find(data => data.res_id === resId)"/>
<td t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state.value : '' }}">
<ActivityRecordAdapter Component="widgetComponents.ActivityRecord"
widgetArgs="[record, { qweb: qweb }]"/>
</td>
<t t-foreach="props.activity_types" t-as="type" t-key="type[0]">
<t t-call="mail.ActivityViewCell"/>
</t>
</tr>
</t>
<t t-name="mail.ActivityViewCell" owl="1">
<t t-set="activityGroup" t-value="props.grouped_activities[resId] and props.grouped_activities[resId][type[0]] or {count: 0, ids: [], state: false}"/>
<t t-set="isCellHidden" t-value="activeFilter.resIds.length and !activeFilter.resIds.includes(resId) and activeFilter.activityTypeId === type[0]"/>
<td t-if="activityGroup.state and !isCellHidden" t-att-data-res-id="resId" t-att-data-activity-type-id="type[0]"
t-attf-class="o_activity_summary_cell h-100 {{activityGroup.state}} {{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state.value : '' }}">
<ActivityCellViewContainer activityIds="activityGroup.ids" activityTypeId="type[0]" closestDeadline="activityGroup.o_closest_deadline" reloadFunc.bind="ev => this.trigger('reload_data')" resId="record.res_id" resModel="record.model"/>
</td>
<td t-else="" t-att-data-res-id="resId" t-att-data-activity-type-id="type[0]"
class="o_activity_summary_cell o_activity_empty_cell cursor-pointer"
t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state.value : '' }}"
t-on-click.prevent.stop="_onEmptyCellClicked">
<i title="Create" class="text-center fa fa-plus mt-2"/>
</td>
</t>
<t t-name="mail.ActivityViewFooter" owl="1">
<tfoot>
<tr class="o_data_row">
<td class="o_record_selector p-3 cursor-pointer" t-on-click.prevent.stop="() => this.trigger('schedule_activity')">
<span class="btn btn-link"><i class="fa fa-plus pe-2"/><span>Schedule activity</span></span>
</td>
</tr>
</tfoot>
</t>
<div t-name="mail.ActivityRenderer" class="o_activity_view" owl="1">
<t t-if="!props.activity_types.length" t-call="web.NoContentHelper"/>
<table t-else="" class="table table-bordered mb-5 bg-view o_activity_view_table">
<t t-call="mail.ActivityViewHeader"/>
<t t-call="mail.ActivityViewBody"/>
<t t-call="mail.ActivityViewFooter"/>
</table>
</div>
</templates>
-1
View File
@@ -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',
@@ -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);
@@ -51,7 +51,7 @@
}
}
.o_kanban_counter_progress .bg-purple {
.o_column_progress .bg-purple {
background-color: purple;
}
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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";
}
@@ -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;
@@ -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";
}
+4 -4
View File
@@ -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":