19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:34 +01:00
parent 5faf7397c5
commit 2696f14ed7
721 changed files with 220375 additions and 91221 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

View file

@ -1,23 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
<defs>
<path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/>
<linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#CDC484"/>
<stop offset="100%" stop-color="#B5AA59"/>
</linearGradient>
<path id="icon-d" d="M28.3839286,37.7083333 L24.9017857,37.7083333 C24.3272321,37.7083333 23.8571429,37.2513021 23.8571429,36.6927083 L23.8571429,33.3072917 C23.8571429,32.7486979 24.3272321,32.2916667 24.9017857,32.2916667 L28.3839286,32.2916667 C28.9584821,32.2916667 29.4285714,32.7486979 29.4285714,33.3072917 L29.4285714,36.6927083 C29.4285714,37.2513021 28.9584821,37.7083333 28.3839286,37.7083333 Z M37.7857143,36.6927083 L37.7857143,33.3072917 C37.7857143,32.7486979 37.315625,32.2916667 36.7410714,32.2916667 L33.2589286,32.2916667 C32.684375,32.2916667 32.2142857,32.7486979 32.2142857,33.3072917 L32.2142857,36.6927083 C32.2142857,37.2513021 32.684375,37.7083333 33.2589286,37.7083333 L36.7410714,37.7083333 C37.315625,37.7083333 37.7857143,37.2513021 37.7857143,36.6927083 Z M46.1428571,36.6927083 L46.1428571,33.3072917 C46.1428571,32.7486979 45.6727679,32.2916667 45.0982143,32.2916667 L41.6160714,32.2916667 C41.0415179,32.2916667 40.5714286,32.7486979 40.5714286,33.3072917 L40.5714286,36.6927083 C40.5714286,37.2513021 41.0415179,37.7083333 41.6160714,37.7083333 L45.0982143,37.7083333 C45.6727679,37.7083333 46.1428571,37.2513021 46.1428571,36.6927083 Z M37.7857143,44.8177083 L37.7857143,41.4322917 C37.7857143,40.8736979 37.315625,40.4166667 36.7410714,40.4166667 L33.2589286,40.4166667 C32.684375,40.4166667 32.2142857,40.8736979 32.2142857,41.4322917 L32.2142857,44.8177083 C32.2142857,45.3763021 32.684375,45.8333333 33.2589286,45.8333333 L36.7410714,45.8333333 C37.315625,45.8333333 37.7857143,45.3763021 37.7857143,44.8177083 Z M29.4285714,44.8177083 L29.4285714,41.4322917 C29.4285714,40.8736979 28.9584821,40.4166667 28.3839286,40.4166667 L24.9017857,40.4166667 C24.3272321,40.4166667 23.8571429,40.8736979 23.8571429,41.4322917 L23.8571429,44.8177083 C23.8571429,45.3763021 24.3272321,45.8333333 24.9017857,45.8333333 L28.3839286,45.8333333 C28.9584821,45.8333333 29.4285714,45.3763021 29.4285714,44.8177083 Z M46.1428571,44.8177083 L46.1428571,41.4322917 C46.1428571,40.8736979 45.6727679,40.4166667 45.0982143,40.4166667 L41.6160714,40.4166667 C41.0415179,40.4166667 40.5714286,40.8736979 40.5714286,41.4322917 L40.5714286,44.8177083 C40.5714286,45.3763021 41.0415179,45.8333333 41.6160714,45.8333333 L45.0982143,45.8333333 C45.6727679,45.8333333 46.1428571,45.3763021 46.1428571,44.8177083 Z M54.5,22.8125 L54.5,52.6041667 C54.5,54.8470052 52.6283482,56.6666667 50.3214286,56.6666667 L19.6785714,56.6666667 C17.3716518,56.6666667 15.5,54.8470052 15.5,52.6041667 L15.5,22.8125 C15.5,20.5696615 17.3716518,18.75 19.6785714,18.75 L23.8571429,18.75 L23.8571429,14.3489583 C23.8571429,13.7903646 24.3272321,13.3333333 24.9017857,13.3333333 L28.3839286,13.3333333 C28.9584821,13.3333333 29.4285714,13.7903646 29.4285714,14.3489583 L29.4285714,18.75 L40.5714286,18.75 L40.5714286,14.3489583 C40.5714286,13.7903646 41.0415179,13.3333333 41.6160714,13.3333333 L45.0982143,13.3333333 C45.6727679,13.3333333 46.1428571,13.7903646 46.1428571,14.3489583 L46.1428571,18.75 L50.3214286,18.75 C52.6283482,18.75 54.5,20.5696615 54.5,22.8125 Z M50.3214286,52.0963542 L50.3214286,26.875 L19.6785714,26.875 L19.6785714,52.0963542 C19.6785714,52.375651 19.9136161,52.6041667 20.2008929,52.6041667 L49.7991071,52.6041667 C50.0863839,52.6041667 50.3214286,52.375651 50.3214286,52.0963542 Z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="icon-b" fill="#fff">
<use xlink:href="#icon-a"/>
</mask>
<g mask="url(#icon-b)">
<rect width="70" height="70" fill="url(#icon-c)"/>
<path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/>
<path fill="#393939" d="M43.4796282,58 L3.97692274,58 C1.98846137,58 -7.06443391e-15,57.8520408 0,53.8571429 L2.26109583e-16,28.7421381 L18.1939643,6.70980496 L20,6 L24,0 L29.235775,0.958543565 L30.1559259,9.58543565 L42.1178875,8.51357709e-15 L45.7984911,0 L45.7984911,8 L54,8 L53.7495748,40.7451155 L43.4796282,58 Z" opacity=".324" transform="translate(0 12)"/>
<path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/>
<use fill="#000" fill-rule="nonzero" opacity=".35" xlink:href="#icon-d"/>
<path fill="#FFF" fill-rule="nonzero" d="M28.3839286,35.7083333 L24.9017857,35.7083333 C24.3272321,35.7083333 23.8571429,35.2513021 23.8571429,34.6927083 L23.8571429,31.3072917 C23.8571429,30.7486979 24.3272321,30.2916667 24.9017857,30.2916667 L28.3839286,30.2916667 C28.9584821,30.2916667 29.4285714,30.7486979 29.4285714,31.3072917 L29.4285714,34.6927083 C29.4285714,35.2513021 28.9584821,35.7083333 28.3839286,35.7083333 Z M37.7857143,34.6927083 L37.7857143,31.3072917 C37.7857143,30.7486979 37.315625,30.2916667 36.7410714,30.2916667 L33.2589286,30.2916667 C32.684375,30.2916667 32.2142857,30.7486979 32.2142857,31.3072917 L32.2142857,34.6927083 C32.2142857,35.2513021 32.684375,35.7083333 33.2589286,35.7083333 L36.7410714,35.7083333 C37.315625,35.7083333 37.7857143,35.2513021 37.7857143,34.6927083 Z M46.1428571,34.6927083 L46.1428571,31.3072917 C46.1428571,30.7486979 45.6727679,30.2916667 45.0982143,30.2916667 L41.6160714,30.2916667 C41.0415179,30.2916667 40.5714286,30.7486979 40.5714286,31.3072917 L40.5714286,34.6927083 C40.5714286,35.2513021 41.0415179,35.7083333 41.6160714,35.7083333 L45.0982143,35.7083333 C45.6727679,35.7083333 46.1428571,35.2513021 46.1428571,34.6927083 Z M37.7857143,42.8177083 L37.7857143,39.4322917 C37.7857143,38.8736979 37.315625,38.4166667 36.7410714,38.4166667 L33.2589286,38.4166667 C32.684375,38.4166667 32.2142857,38.8736979 32.2142857,39.4322917 L32.2142857,42.8177083 C32.2142857,43.3763021 32.684375,43.8333333 33.2589286,43.8333333 L36.7410714,43.8333333 C37.315625,43.8333333 37.7857143,43.3763021 37.7857143,42.8177083 Z M29.4285714,42.8177083 L29.4285714,39.4322917 C29.4285714,38.8736979 28.9584821,38.4166667 28.3839286,38.4166667 L24.9017857,38.4166667 C24.3272321,38.4166667 23.8571429,38.8736979 23.8571429,39.4322917 L23.8571429,42.8177083 C23.8571429,43.3763021 24.3272321,43.8333333 24.9017857,43.8333333 L28.3839286,43.8333333 C28.9584821,43.8333333 29.4285714,43.3763021 29.4285714,42.8177083 Z M46.1428571,42.8177083 L46.1428571,39.4322917 C46.1428571,38.8736979 45.6727679,38.4166667 45.0982143,38.4166667 L41.6160714,38.4166667 C41.0415179,38.4166667 40.5714286,38.8736979 40.5714286,39.4322917 L40.5714286,42.8177083 C40.5714286,43.3763021 41.0415179,43.8333333 41.6160714,43.8333333 L45.0982143,43.8333333 C45.6727679,43.8333333 46.1428571,43.3763021 46.1428571,42.8177083 Z M54.5,20.8125 L54.5,50.6041667 C54.5,52.8470052 52.6283482,54.6666667 50.3214286,54.6666667 L19.6785714,54.6666667 C17.3716518,54.6666667 15.5,52.8470052 15.5,50.6041667 L15.5,20.8125 C15.5,18.5696615 17.3716518,16.75 19.6785714,16.75 L23.8571429,16.75 L23.8571429,12.3489583 C23.8571429,11.7903646 24.3272321,11.3333333 24.9017857,11.3333333 L28.3839286,11.3333333 C28.9584821,11.3333333 29.4285714,11.7903646 29.4285714,12.3489583 L29.4285714,16.75 L40.5714286,16.75 L40.5714286,12.3489583 C40.5714286,11.7903646 41.0415179,11.3333333 41.6160714,11.3333333 L45.0982143,11.3333333 C45.6727679,11.3333333 46.1428571,11.7903646 46.1428571,12.3489583 L46.1428571,16.75 L50.3214286,16.75 C52.6283482,16.75 54.5,18.5696615 54.5,20.8125 Z M50.3214286,50.0963542 L50.3214286,24.875 L19.6785714,24.875 L19.6785714,50.0963542 C19.6785714,50.375651 19.9136161,50.6041667 20.2008929,50.6041667 L49.7991071,50.6041667 C50.0863839,50.6041667 50.3214286,50.375651 50.3214286,50.0963542 Z"/>
</g>
</g>
</svg>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M16 38c3 0 6-1 6-5s-2.306-6-6-6a3 3 0 0 1 3-3h4c3.593 1.3 6 4 6 9 0 8-6 11-13 11S4 41 4 33h7c0 3 2 5 5 5Z" fill="#985184"/><path d="M23 24h-5a2 2 0 0 1-2-2c4 0 5-3 5-5 0-3-1.964-5-5-5-3 0-5 2-5 4H4C4 9.616 10 6 16 6s12 4 12 10-5 8-5 8Z" fill="#FBB945"/><path d="m23 24-7 3h-4v-2c0-2 1-3 4-3l7 2Z" fill="#FC868B"/><path d="M46 11v33h-3c-2 0-4-2-4-4V14l7-3Z" fill="#FBB945"/><path d="m39 7.334-7 4.667-1 6 8-4V7.334Z" fill="#985184"/><path d="M39 7.333 41 6h1c2 0 4 2 4 4v1l-7 3V7.333Z" fill="#FC868B"/></svg>

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 600 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,5 @@
declare module "models" {
export interface Activity {
rescheduleMeeting: () => Promise<void>;
}
}

View file

@ -0,0 +1,12 @@
import { ActivityListPopoverItem } from "@mail/core/web/activity_list_popover_item";
import { patch } from "@web/core/utils/patch";
patch(ActivityListPopoverItem.prototype, {
get hasEditButton() {
return super.hasEditButton && !this.props.activity.calendar_event_id;
},
async onClickReschedule() {
await this.props.activity.rescheduleMeeting();
},
});

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.ActivityListPopoverItem" t-inherit-mode="extension">
<xpath expr="//button[hasclass('o-mail-ActivityListPopoverItem-editbtn')]" position="after">
<button t-if="props.activity.calendar_event_id" class="o-mail-ActivityListPopoverItem-editbtn btn btn-sm btn-success btn-link" t-on-click="onClickReschedule">
<i class="fa fa-calendar"/>
</button>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,20 @@
import { ActivityMenu } from "@mail/core/web/activity_menu";
import { patch } from "@web/core/utils/patch";
patch(ActivityMenu.prototype, {
openActivityGroup(group, filter, newWindow) {
if (group.model === "calendar.event") {
this.dropdown.close();
this.action.doAction("calendar.action_calendar_event", {
newWindow,
additionalContext: {
default_mode: "day",
search_default_mymeetings: 1,
},
clearBreadcrumbs: true,
});
} else {
super.openActivityGroup(...arguments);
}
},
});

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.ActivityMenu" t-inherit-mode="extension">
<xpath expr="//*[@name='activityTitle']" position="after">
<div t-if="group.type === 'meeting'">
<t t-set="is_next_meeting" t-value="true"/>
<t t-foreach="group.meetings" t-as="meeting" t-key="meeting.id">
<div class="o-calendar-meeting d-flex px-2">
<span class="flex-grow-1 text-truncate" t-att-class="!meeting.allday and is_next_meeting ? 'fw-bold' : ''" t-esc="meeting.name"/>
<span t-if="meeting.formattedStart">
<t t-if="meeting.allday">All Day</t>
<t t-else=''>
<t t-set="is_next_meeting" t-value="false"/>
<t t-esc="meeting.formattedStart"/>
</t>
</span>
</div>
</t>
</div>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,24 @@
import { Activity } from "@mail/core/common/activity_model";
import { assignIn } from "@mail/utils/common/misc";
import { patch } from "@web/core/utils/patch";
patch(Activity, {
_insert(data) {
const activity = super._insert(...arguments);
assignIn(activity, data, ["calendar_event_id"]);
return activity;
},
});
/** @type {import("models").Activity} */
const activityPatch = {
async rescheduleMeeting() {
const action = await this.store.env.services.orm.call(
"mail.activity",
"action_create_calendar_event",
[[this.id]]
);
this.store.env.services.action.doAction(action);
},
};
patch(Activity.prototype, activityPatch);

View file

@ -0,0 +1,26 @@
import { Activity } from "@mail/core/web/activity";
import { useService } from "@web/core/utils/hooks";
import { patch } from "@web/core/utils/patch";
patch(Activity.prototype, {
setup() {
super.setup();
this.orm = useService("orm");
},
async onClickReschedule() {
await this.props.activity.rescheduleMeeting();
},
/**
* @override
*/
async unlink() {
if (this.props.activity.calendar_event_id) {
const thread = this.thread;
this.props.activity.remove();
await this.orm.call("mail.activity", "unlink_w_meeting", [[this.props.activity.id]]);
this.props.onActivityChanged(thread);
} else {
super.unlink();
}
},
});

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="mail.Activity" t-inherit-mode="extension">
<xpath expr="//button[hasclass('o-mail-Activity-edit')]" position="attributes">
<attribute name="t-if">!props.activity.calendar_event_id</attribute>
</xpath>
<xpath expr="//button[hasclass('o-mail-Activity-edit')]" position="after">
<button t-if="props.activity.calendar_event_id" class="btn btn-link p-0 me-3" t-on-click="onClickReschedule">
<i class="fa fa-calendar"/> Reschedule
</button>
</xpath>
</t>
</templates>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="mail.Activity" t-inherit-mode="extension">
<xpath expr="//button[hasclass('o_Activity_editButton')]" position="attributes">
<attribute name="t-if">!activityView.activity.calendar_event_id</attribute>
</xpath>
<xpath expr="//button[hasclass('o_Activity_editButton')]" position="after">
<t t-if="activityView.activity.calendar_event_id">
<button class="o_Activity_toolButton o_Activity_editButton btn btn-link pt-0" t-on-click="activityView.onClickEdit">
<i class="fa fa-calendar"/> Reschedule
</button>
</t>
</xpath>
</t>
</templates>

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.ActivityMenuView" t-inherit-mode="extension">
<xpath expr="//*[hasclass('o_ActivityMenuView_activityGroupTitle')]" position="after">
<div t-if="activityGroupView.activityGroup.type == 'meeting'">
<t t-set="is_next_meeting" t-value="true"/>
<t t-foreach="activityGroupView.activityGroup.meetings" t-as="meeting" t-key="meeting.localId">
<div class="d-flex">
<span class="flex-grow-1" t-att-class="!meeting.allday and is_next_meeting ? 'o_meeting_filter o_meeting_bold' : 'o_meeting_filter'" t-att-data-res_model="activityGroupView.activityGroup.irModel.model" t-att-data-res_id="meeting.id" t-att-data-model_name="activityGroupView.activityGroup.irModel.name" t-att-title="meeting.name">
<span><t t-esc="meeting.name"/></span>
</span>
<span t-if="meeting.formattedStart">
<t t-if="meeting.allday">All Day</t>
<t t-else=''>
<t t-set="is_next_meeting" t-value="false"/>
<t t-esc="meeting.formattedStart"/>
</t>
</span>
</div>
</t>
</div>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,75 @@
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
const providerData = {
google: {
restart_sync_method: "restart_google_synchronization",
sync_route: "/google_calendar/sync_data",
},
microsoft: {
restart_sync_method: "restart_microsoft_synchronization",
sync_route: "/microsoft_calendar/sync_data",
},
};
export class CalendarConnectProvider extends Component {
static props = {
...standardWidgetProps,
};
static template = "calendar.CalendarConnectProvider";
setup() {
super.setup();
this.orm = useService("orm");
}
/**
* Activate the external sync for the first time, after installing the
* relevant submodule if necessary.
*
* @private
*/
async onConnect(ev) {
ev.preventDefault();
ev.stopImmediatePropagation();
if (!(await this.props.record.save())) {
return; // handled by view
}
await this.orm.call(
this.props.record.resModel,
"action_calendar_prepare_external_provider_sync",
[this.props.record.resId]
);
// See google/microsoft_calendar for the origin of this shortened version
const { restart_sync_method, sync_route } =
providerData[this.props.record.data.external_calendar_provider];
await this.orm.call("res.users", restart_sync_method, [[user.userId]]);
const response = await rpc(sync_route, {
model: "calendar.event",
fromurl: window.location.href,
});
await this._beforeLeaveContext();
if (response.status === "need_auth") {
window.location.assign(response.url);
} else if (response.status === "need_refresh") {
window.location.reload();
}
}
/**
* Hook to perform additional work before redirecting to external url or reloading.
*
* @private
*/
async _beforeLeaveContext() {
return Promise.resolve();
}
}
const calendarConnectProvider = {
component: CalendarConnectProvider,
};
registry.category("view_widgets").add("calendar_connect_provider", calendarConnectProvider);

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="calendar.CalendarConnectProvider">
<a role="button" title="Connect" class="o_calendar_activate_external_cal btn btn-primary"
t-on-click="onConnect">Connect</a>
</t>
</templates>

View file

@ -0,0 +1,25 @@
import { Store } from "@mail/core/common/store_service";
import { deserializeDateTime, formatDateTime } from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
import { patch } from "@web/core/utils/patch";
/** @type {import("models").Store} */
const StorePatch = {
onUpdateActivityGroups() {
super.onUpdateActivityGroups(...arguments);
for (const group of Object.values(this.activityGroups)) {
if (group.type === "meeting") {
for (const meeting of group.meetings) {
if (meeting.start) {
const date = deserializeDateTime(meeting.start);
meeting.formattedStart = formatDateTime(date, {
format: localization.timeFormat,
});
}
}
}
}
},
};
patch(Store.prototype, StorePatch);

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

View file

@ -1,85 +0,0 @@
/** @odoo-module **/
import BasicModel from 'web.BasicModel';
import fieldRegistry from 'web.field_registry';
import relationalFields from 'web.relational_fields';
const FieldMany2ManyTagsAvatar = relationalFields.FieldMany2ManyTagsAvatar;
BasicModel.include({
/**
* @private
* @param {Object} record
* @param {string} fieldName
* @returns {Promise}
*/
_fetchSpecialAttendeeStatus: function (record, fieldName) {
var context = record.getContext({fieldName: fieldName});
var attendeeIDs = record.data[fieldName] ? this.localData[record.data[fieldName]].res_ids : [];
var meetingID = _.isNumber(record.res_id) ? record.res_id : false;
return this._rpc({
model: 'res.partner',
method: 'get_attendee_detail',
args: [attendeeIDs, [meetingID]],
context: context,
}).then(function (result) {
return result;
});
},
});
const Many2ManyAttendee = FieldMany2ManyTagsAvatar.extend({
// as this widget is model dependant (rpc on res.partner), use it in another
// context probably won't work
// supportedFieldTypes: ['many2many'],
specialData: "_fetchSpecialAttendeeStatus",
className: 'o_field_many2manytags avatar',
init: function () {
this._super.apply(this, arguments);
this.className += this.nodeOptions.block ? ' d-block' : '';
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
*/
_renderTags: function () {
this._super.apply(this, arguments);
const avatars = this.el.querySelectorAll('.o_m2m_avatar');
for (const avatar of avatars) {
const partner_id = parseInt(avatar.dataset["id"]);
const partner_data = this.record.specialData.partner_ids.find(partner => partner.id === partner_id);
if (partner_data) {
avatar.classList.add('o_attendee_border', "o_attendee_border_" + partner_data.status);
}
}
},
/**
* @override
* @private
*/
_getRenderTagsContext: function () {
let result = this._super.apply(this, arguments);
result.attendeesData = this.record.specialData.partner_ids;
// Sort attendees to have the organizer on top.
// partner_ids are sorted by default according to their id/display_name in the "elements" FieldMany2ManyTag
// This method sort them to put the organizer on top
const organizer = result.attendeesData.find(item => item.is_organizer);
if (organizer) {
const org_id = organizer.id
// sort elements according to the partner id
result.elements.sort((a, b) => {
const a_org = a.id === org_id;
return a_org ? -1 : 1;
});
}
return result;
},
});
fieldRegistry.add('many2manyattendee', Many2ManyAttendee);

View file

@ -1,23 +1,18 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { browser } from "@web/core/browser/browser";
import { ConnectionLostError } from "@web/core/network/rpc_service";
import { ConnectionLostError, rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
export const calendarNotificationService = {
dependencies: ["action", "bus_service", "notification", "rpc"],
dependencies: ["action", "bus_service", "notification"],
start(env, { action, bus_service, notification, rpc }) {
start(env, { action, bus_service, notification }) {
let calendarNotifTimeouts = {};
let nextCalendarNotifTimeout = null;
const displayedNotifications = new Set();
bus_service.addEventListener('notification', ({ detail: notifications }) => {
for (const { payload, type } of notifications) {
if (type === "calendar.alarm") {
displayCalendarNotification(payload);
}
}
bus_service.subscribe("calendar.alarm", (payload) => {
displayCalendarNotification(payload);
});
bus_service.start();
@ -48,7 +43,7 @@ export const calendarNotificationService = {
},
buttons: [
{
name: env._t("OK"),
name: _t("OK"),
primary: true,
onClick: async () => {
await rpc("/calendar/notify_ack");
@ -56,20 +51,19 @@ export const calendarNotificationService = {
},
},
{
name: env._t("Details"),
name: _t("Details"),
onClick: async () => {
await action.doAction({
type: 'ir.actions.act_window',
res_model: 'calendar.event',
type: "ir.actions.act_window",
res_model: "calendar.event",
res_id: notif.event_id,
views: [[false, 'form']],
}
);
views: [[false, "form"]],
});
notificationRemove();
},
},
{
name: env._t("Snooze"),
name: _t("Snooze"),
onClick: () => {
notificationRemove();
},

View file

@ -1,63 +0,0 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
import { attr } from '@mail/model/model_field';
registerPatch({
name: 'Activity',
modelMethods: {
/**
* @override
*/
convertData(data) {
const res = this._super(data);
if ('calendar_event_id' in data) {
res.calendar_event_id = data.calendar_event_id[0];
}
return res;
},
},
recordMethods: {
/**
* @override
*/
async deleteServerRecord() {
if (!this.calendar_event_id){
await this._super();
} else {
await this.messaging.rpc({
model: 'mail.activity',
method: 'unlink_w_meeting',
args: [[this.id]],
});
if (!this.exists()) {
return;
}
this.delete();
}
},
/**
* In case the activity is linked to a meeting, we want to open the
* calendar view instead.
*
* @override
*/
async edit() {
if (!this.calendar_event_id){
await this._super();
} else {
const action = await this.messaging.rpc({
model: 'mail.activity',
method: 'action_create_calendar_event',
args: [[this.id]],
});
this.env.services.action.doAction(action);
}
},
},
fields: {
calendar_event_id: attr({
default: false,
}),
},
});

View file

@ -1,34 +0,0 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
import { many } from '@mail/model/model_field';
registerPatch({
name: 'ActivityGroup',
modelMethods: {
/**
* @override
*/
convertData(data) {
const data2 = this._super(data);
data2.meetings = data.meetings;
return data2;
},
},
recordMethods: {
_onChangeMeetings() {
if (this.type === 'meeting' && this.meetings.length === 0) {
this.delete();
}
},
},
fields: {
meetings: many('calendar.event'),
},
onChanges: [
{
dependencies: ['meetings', 'type'],
methodName: '_onChangeMeetings',
},
],
});

View file

@ -1,28 +0,0 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
registerPatch({
name: 'ActivityGroupView',
recordMethods: {
/**
* @override
*/
onClickFilterButton(ev) {
const $el = $(ev.currentTarget);
const data = _.extend({}, $el.data());
if (data.res_model === "calendar.event" && data.filter === "my") {
this.activityMenuViewOwner.update({ isOpen: false });
this.env.services['action'].doAction('calendar.action_calendar_event', {
additionalContext: {
default_mode: 'day',
search_default_mymeetings: 1,
},
clearBreadcrumbs: true,
});
} else {
this._super.apply(this, arguments);
}
},
},
});

View file

@ -1,29 +0,0 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
import Dialog from 'web.Dialog';
registerPatch({
name: 'ActivityView',
recordMethods: {
/**
* @override
*/
async onClickCancel(ev) {
if (this.activity.calendar_event_id) {
await new Promise(resolve => {
Dialog.confirm(
this,
this.env._t("The activity is linked to a meeting. Deleting it will remove the meeting as well. Do you want to proceed?"),
{ confirm_callback: resolve },
);
});
}
if (!this.exists()) {
return;
}
await this._super();
},
},
});

View file

@ -1,29 +0,0 @@
/** @odoo-module **/
import { registerModel } from '@mail/model/model_core';
import { attr } from '@mail/model/model_field';
import { clear } from '@mail/model/model_field_command';
import fieldUtils from 'web.field_utils';
import { getLangTimeFormat } from 'web.time';
registerModel({
name: 'calendar.event',
fields: {
allday: attr(),
attendee_status: attr(),
formattedStart: attr({
compute() {
if (!this.start) {
return clear();
}
return moment(fieldUtils.parse.datetime(this.start, false, { isUTC: true })).local().format(getLangTimeFormat());
},
}),
id: attr({
identifying: true,
}),
name: attr(),
start: attr(),
},
});

View file

@ -1,69 +1,11 @@
.o_calendar_invitation {
@extend .o_status;
.o_cw_popover .o_cw_popover_fields_secondary {
max-height: 75vh;
&.accepted {
background-color: map-get($theme-colors, 'success');
}
&.tentative {
background-color: $o-main-color-muted;
}
&.declined {
background-color: map-get($theme-colors, 'danger');
& > li > div {
min-width: 0;
}
}
.o_add_favorite_calendar {
margin-top: 10px;
position: relative;
}
.o_calendar_invitation_page {
flex: 0 0 auto;
width: 50%;
margin: 30px auto 0;
@include o-webclient-padding($top: 10px, $bottom: 10px);
background-color: $o-view-background-color;
.o_logo {
width: 15%;
}
.o_event_title {
margin-left: 20%;
h2 {
margin-top: 0;
}
}
.o_event_table {
clear: both;
margin: 15px 0 0;
th {
padding-right: 15px;
}
ul {
padding-left: 0;
}
}
.o_accepted {
@extend .text-success;
}
.o_declined {
@extend .text-danger;
}
}
.o_meeting_filter {
@include o-text-overflow();
min-width: 0;
color: grey;
vertical-align: top;
&.o_meeting_bold {
font-weight: bold;
}
}
.o_cw_body .o_field_copy {
max-width: calc(100% - 6rem);
@ -74,93 +16,94 @@
padding-top: 0px !important;
}
.o_calendar_attendees {
max-width:80% !important;
.o_cw_body li:has(.o_field_html[name="description"]){
span:has(i) {
align-self: flex-start;
}
.o_field_html {
text-wrap: wrap;
}
}
.o_attendee_border {
border-width: 2px;
border-style: solid;
.o_field_many2manyattendeeexpandable .o_field_tags {
flex-direction: column;
span.badge {
width: fit-content;
}
}
.o_attendee_border_accepted {
border-color: map-get($theme-colors, 'success');
}
.o_attendee_border_declined {
border-color: map-get($theme-colors, 'danger');
}
.o_attendee_border_tentative {
border-color: map-get($theme-colors, 'light');
}
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
.o_cw_popover_link.o_calendar_color_#{$i - 1} {
&.o_attendee_status_tentative {
color: color-contrast($color);
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba($color, 0.7) 10px,
rgba($color, 0.7) 20px
) !important;
.o_calendar_renderer .fc-event {
&:not(.o_event_dot) {
&.o_attendee_status_active {
--o-bg-opacity: 1;
}
&.o_attendee_status_tentative {
--o-bg-opacity: .5;
}
&.o_attendee_status_alone,
&.o_attendee_status_needsAction {
background-color: rgba($o-view-background-color, 0.9) !important;
--o-bg-opacity: .5;
}
&.o_attendee_status_declined {
text-decoration: line-through;
background-color: rgba($o-view-background-color, 0.9) !important;
--o-bg-opacity: 0;
}
}
.o_calendar_renderer {
.fc-event.o_calendar_color_#{$i - 1} {
&.o_attendee_status_needsAction,
&.o_attendee_status_tentative,
&.o_attendee_status_declined,
&.o_attendee_status_alone {
border-width: 2px 2px 2px !important;
&.o_cw_custom_highlight {
background-color: $color;
}
&.o_event_dot {
&.o_attendee_status_needsAction {
&:before {
content: "\f1db"; // fa-circle-thin
}
}
&.o_attendee_status_tentative {
color: color-contrast($color) !important;
.fc-bg {
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba(white, 0.4) 10px,
rgba(white, 0.4) 20px
) !important;
}
}
&.o_attendee_status_tentative:before {
content: "\f059"; // fa-question-circle
}
&.o_attendee_status_alone,
&.o_attendee_status_needsAction {
background-color: rgba($o-view-background-color, 0.9) !important;
color: $o-black !important;
.fc-bg {
background-color: rgba($o-view-background-color, 0.9) !important;
}
}
&.o_attendee_status_declined:before {
content: "\f05e"; // fa-ban
}
&.o_attendee_status_declined {
text-decoration: line-through;
background-color: rgba($o-view-background-color, 0.9) !important;
color: $o-black !important;
.fc-bg {
background-color: rgba($o-view-background-color, 0.9) !important;
}
}
&.o_attendee_status_alone {
content: "\f06a"; // fa-exclamation-circle
}
}
&.o_attendee_status_declined, &.o_event_striked {
.fc-event-main {
text-decoration: line-through;
}
}
}
.btn:hover .o_calendar_check {
display: none;
}
.btn:hover .o_calendar_pause {
display: none;
}
.btn:not(:hover) .o_calendar_stop {
display: none;
}
.btn-group .btn.active {
z-index: 2;
}
.o_text_green {
color: green;
}
.o_text_red {
color: red;
}
.o_text_orange {
color: orange;
}

View file

@ -0,0 +1,34 @@
/* Form View
**********************************************************/
.o_calendar_event_form_view {
.o_calendar_form_status_selection_badge {
> div {
margin-bottom: 0 !important;
}
.o_selection_badge {
margin-bottom: 0 !important;
}
}
}
/* Quick-Edit Form View
**********************************************************/
.modal-dialog:has(.o_calendar_event_form_quick_create) {
--modal-width: 490px;
}
.o_calendar_event_form_quick_create {
.o_form_nosheet {
padding: 0;
> div {
margin-top: 0.5rem;
margin-left: 5px;
margin-right: 5px;
}
}
i.fa {
text-align: center;
min-width: 2rem;
}
}

View file

@ -1,29 +1,36 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { Dialog } from "@web/core/dialog/dialog";
const { Component } = owl;
import { Component } from "@odoo/owl";
export class AskRecurrenceUpdatePolicyDialog extends Component {
static template = "calendar.AskRecurrenceUpdatePolicyDialog";
static components = {
Dialog,
};
static props = {
confirm: Function,
close: Function,
};
setup() {
this.possibleValues = {
self_only: {
checked: true,
label: this.env._t("This event"),
label: _t("This event"),
},
future_events: {
checked: false,
label: this.env._t("This and following events"),
label: _t("This and following events"),
},
all_events: {
checked: false,
label: this.env._t("All events"),
label: _t("All events"),
},
};
}
get selected() {
return Object.entries(this.possibleValues).find(state => state[1].checked)[0];
return Object.entries(this.possibleValues).find((state) => state[1].checked)[0];
}
set selected(val) {
@ -36,7 +43,3 @@ export class AskRecurrenceUpdatePolicyDialog extends Component {
this.props.close();
}
}
AskRecurrenceUpdatePolicyDialog.template = "calendar.AskRecurrenceUpdatePolicyDialog";
AskRecurrenceUpdatePolicyDialog.components = {
Dialog,
};

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.AskRecurrenceUpdatePolicyDialog" owl="1">
<Dialog size="'sm'" title="env._t('Edit Recurrent event')">
<t t-name="calendar.AskRecurrenceUpdatePolicyDialog">
<Dialog size="'sm'" title.translate="Edit Recurrent event">
<t t-foreach="Object.entries(possibleValues)" t-as="value" t-key="value[0]">
<t t-set="name" t-value="value[0]"/>
<t t-set="state" t-value="value[1]"/>
@ -10,7 +10,7 @@
<label class="form-check-label o_form_label" t-att-for="name" t-esc="state.label"/>
</div>
</t>
<t t-set-slot="footer" owl="1">
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="confirm">
Confirm
</button>

View file

@ -1,5 +1,3 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { AskRecurrenceUpdatePolicyDialog } from "@calendar/views/ask_recurrence_update_policy_dialog";

View file

@ -1,28 +1,72 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { CalendarController } from "@web/views/calendar/calendar_controller";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { onWillStart } from "@odoo/owl";
import { CalendarQuickCreate } from "@calendar/views/calendar_form/calendar_quick_create";
export class AttendeeCalendarController extends CalendarController {
static template = "calendar.AttendeeCalendarController";
static components = {
...AttendeeCalendarController.components,
QuickCreateFormView: CalendarQuickCreate,
};
setup() {
super.setup();
this.actionService = useService("action");
this.user = useService("user");
this.orm = useService("orm");
onWillStart(async () => {
this.isSystemUser = await this.user.hasGroup('base.group_system');
this.isSystemUser = await user.hasGroup("base.group_system");
});
}
onClickAddButton() {
this.actionService.doAction({
type: 'ir.actions.act_window',
res_model: 'calendar.event',
views: [[false, 'form']],
}, {
additionalContext: this.props.context,
});
this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "calendar.event",
views: [[false, "form"]],
},
{
additionalContext: this.props.context,
}
);
}
goToFullEvent(resId, additionalContext) {
this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "calendar.event",
views: [[false, "form"]],
res_id: resId || false,
},
{
additionalContext: {
...this.props.context,
...additionalContext,
},
}
);
}
getQuickCreateFormViewProps(record) {
const props = super.getQuickCreateFormViewProps(record);
const onDialogClosed = () => {
this.model.load();
};
return {
...props,
size: "md",
context: { ...props.context, ...this.props.context },
onRecordSaved: () => onDialogClosed(),
};
}
async editRecord(record, context = {}) {
if (record.id) {
return this.goToFullEvent(record.id, context);
}
}
/**
@ -31,31 +75,71 @@ export class AttendeeCalendarController extends CalendarController {
* If the event is deleted by the organizer, the event is deleted, otherwise it is declined.
*/
deleteRecord(record) {
if (this.user.partnerId === record.attendeeId && this.user.partnerId === record.rawRecord.partner_id[0]) {
super.deleteRecord(...arguments);
if (
user.partnerId === record.attendeeId &&
user.partnerId === record.rawRecord.partner_id[0]
) {
if (record.rawRecord.recurrency) {
this.openRecurringDeletionWizard(record);
} else if (user.partnerId === record.attendeeId &&
record.rawRecord.attendees_count == 1) {
super.deleteRecord(...arguments);
} else {
this.orm.call("calendar.event", "action_unlink_event", [
record.id,
record.attendeeId,
])
.then((action) => {
if (action && action.context) {
this.actionService.doAction(action);
} else {
location.reload();
}
});
}
} else {
// Decline event
this.orm.call(
"calendar.attendee",
"do_decline",
[record.calendarAttendeeId],
).then(this.model.load.bind(this.model));
this.orm
.call("calendar.attendee", "do_decline", [record.calendarAttendeeId])
.then(this.model.load.bind(this.model));
}
}
openRecurringDeletionWizard(record) {
this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "calendar.popover.delete.wizard",
views: [[false, "form"]],
view_mode: "form",
name: "Delete Recurring Event",
context: {
default_calendar_event_id: record.id,
default_attendee_id: record.attendeeId,
form_view_ref: 'calendar.calendar_popover_delete_view',
},
target: "new",
},
{
onClose: () => {
this.model.load();
},
}
);
}
configureCalendarProviderSync(providerName) {
this.actionService.doAction({
name: this.env._t('Connect your Calendar'),
type: 'ir.actions.act_window',
res_model: 'calendar.provider.config',
name: _t("Connect your Calendar"),
type: "ir.actions.act_window",
res_model: "calendar.provider.config",
views: [[false, "form"]],
view_mode: "form",
target: 'new',
target: "new",
context: {
'default_external_calendar_provider': providerName,
'dialog_size': 'medium',
}
default_external_calendar_provider: providerName,
dialog_size: "medium",
},
});
}
}
AttendeeCalendarController.template = "calendar.AttendeeCalendarController";

View file

@ -1,32 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.AttendeeCalendarController" t-inherit="web.CalendarController" t-inherit-mode="primary" owl="1">
<DatePicker position="after">
<div id="calendar_sync_wrapper" t-if="isSystemUser">
<div id="calendar_sync_title" class="o_calendar_sync text-center">
<span class="text-primary fs-6">Synchronize with:</span>
</div>
<div id="calendar_sync" class="container inline btn-group justify-content-evenly align-items-center">
<div id="google_calendar_sync" class="o_calendar_sync" t-if="isSystemUser">
<button type="button" id="google_sync_activate" class="btn btn-muted" t-on-click="() => this.configureCalendarProviderSync('google')">
<b><i class='fa fa-plug'/> Google</b>
<t t-name="calendar.AttendeeCalendarController" t-inherit="web.CalendarController" t-inherit-mode="primary">
<xpath expr="//Layout" position="inside">
<t t-set-slot="control-panel-create-button">
<button class="btn btn-primary o-calendar-button-new" t-on-click="onClickAddButton">New</button>
</t>
</xpath>
<!-- Add header div to be filled with synchronization buttons in synchronization modules. -->
<xpath expr="//Layout//div[hasclass('o_calendar_container')]//h5[hasclass('d-inline-flex')]" position="after">
<div id="header_synchronization_settings" class="mx-2 ms-lg-auto">
<t t-if="(model.credentialStatus.google_calendar or model.credentialStatus.microsoft_calendar)
and (!model.syncStatus.google_calendar or ['sync_stopped', 'missing_credentials'].includes(model.syncStatus.google_calendar))
and (!model.syncStatus.microsoft_calendar or ['sync_stopped', 'missing_credentials'].includes(model.syncStatus.microsoft_calendar))">
<h5 id="synchronize_with" class="d-inline-flex" >Synchronize with</h5>
</t>
<t t-elif="(model.googleCredentialsSet and model.syncStatus.google_calendar == 'sync_active')
or (model.microsoftCredentialsSet and model.syncStatus.microsoft_calendar == 'sync_active')">
<div id="google_calendar_sync" class="o_calendar_sync mx-1 d-inline-flex">
<button class="btn text-nowrap"
t-on-click="model.syncStatus.google_calendar == 'sync_active' ? onStopGoogleSynchronization : onStopMicrosoftSynchronization">
<div>
<i id="check_symbol" class='fa fa-check px-1 o_text_green o_calendar_check'/>
<i id="stop_symbol" class='fa fa-square px-1 o_text_red o_calendar_stop'/>
<span id="check_text" class='o_calendar_check'>
<t t-if="model.syncStatus.google_calendar == 'sync_active'">Google</t>
<t t-else="">Outlook</t>
</span>
<span id="stop_text" class="o_text_red o_calendar_stop">Stop synchro</span>
</div>
</button>
</div>
<div id="microsoft_calendar_sync" class="o_calendar_sync" t-if="isSystemUser">
<button type="button" id="microsoft_sync_activate" class="btn btn-muted" t-on-click="() => this.configureCalendarProviderSync('microsoft')">
<b><i class='fa fa-plug'/> Outlook</b>
</button>
</div>
</div>
</t>
<t t-elif="model.syncStatus.google_calendar == 'sync_paused' or model.syncStatus.microsoft_calendar =='sync_paused'">
<button class="btn text-nowrap" t-on-click="model.syncStatus.google_calendar == 'sync_paused' ? onUnpauseGoogleSynchronization : onUnpauseMicrosoftSynchronization">
<div>
<i id="pause_symbol" class='fa fa-pause px-1 o_text_orange o_calendar_pause'/>
<i id="stop_symbol" class='fa fa-square px-1 o_text_red o_calendar_stop'/>
<span id="pause_text" class='o_text_orange o_calendar_pause'>Synchro is paused</span>
<span id="stop_text" class="o_text_red o_calendar_stop">Stop synchro</span>
</div>
</button>
</t>
</div>
</DatePicker>
</t>
<t t-name="calendar.AttendeeCalendarController.controlButtons" t-inherit="web.CalendarController.controlButtons" owl="1">
<xpath expr="//span[hasclass('o_calendar_navigation_buttons')]" position="before">
<span class="o_calendar_create_buttons">
<button class="btn btn-primary o-calendar-button-new me-1" t-on-click="onClickAddButton">New</button>
</span>
</xpath>
<xpath expr="//t[@t-elif]//span[hasclass('o_current_week')]" position="attributes">
<attribute name="t-if" add="!env.isSmall" separator="and"/>
</xpath>
</t>
</templates>

View file

@ -1,13 +1,39 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { rpc } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { CalendarModel } from "@web/views/calendar/calendar_model";
import { askRecurrenceUpdatePolicy } from "@calendar/views/ask_recurrence_update_policy_hook";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import {
deleteConfirmationMessage,
ConfirmationDialog,
} from "@web/core/confirmation_dialog/confirmation_dialog";
export class AttendeeCalendarModel extends CalendarModel {
setup(params, { dialog }) {
static services = [...CalendarModel.services, "dialog", "orm"];
setup(params, services) {
super.setup(...arguments);
this.dialog = dialog;
this.dialog = services.dialog;
this.rpc = rpc;
}
/**
* @override
*/
async load() {
const res = await super.load(...arguments);
if (!this._loaded) {
const [credentialStatus, syncStatus, defaultDuration] = await Promise.all([
rpc("/calendar/check_credentials"),
this.orm.call("res.users", "check_synchronization_status", [[user.userId]]),
this.orm.call("calendar.event", "get_default_duration"),
]);
this.syncStatus = syncStatus;
this.credentialStatus = credentialStatus;
this.defaultDuration = defaultDuration;
this._loaded = true;
}
return res;
}
get attendees() {
@ -35,7 +61,10 @@ export class AttendeeCalendarModel extends CalendarModel {
* @override
*/
buildRawRecord(partialRecord, options = {}) {
const result = super.buildRawRecord(...arguments);
const result = super.buildRawRecord(partialRecord, {
...options,
duration_hour: this.defaultDuration,
});
if (partialRecord.recurrenceUpdate) {
result.recurrence_update = partialRecord.recurrenceUpdate;
}
@ -43,9 +72,21 @@ export class AttendeeCalendarModel extends CalendarModel {
}
/**
* Load the filter section and add both 'user' and 'everybody' filters to the context.
* @override
*/
async loadFilterSection(fieldName, filterInfo, previousSection) {
const result = await super.loadFilterSection(fieldName, filterInfo, previousSection);
if (result?.filters) {
user.updateContext({
calendar_filters: {
all: result?.filters?.find((f) => f.type == "all")?.active ?? false,
user: result?.filters?.find((f) => f.type == "user")?.active ?? false,
},
});
}
return result;
}
/**
* @override
@ -64,29 +105,34 @@ export class AttendeeCalendarModel extends CalendarModel {
const attendeeFilters = data.filterSections.partner_ids;
let isEveryoneFilterActive = false;
let attendeeIds = [];
const eventIds = Object.keys(data.records).map(id => Number.parseInt(id));
const eventIds = Object.keys(data.records).map((id) => Number.parseInt(id));
if (attendeeFilters) {
const allFilter = attendeeFilters.filters.find(filter => filter.type === "all")
isEveryoneFilterActive = allFilter && allFilter.active || false;
attendeeIds = attendeeFilters.filters.filter(filter => filter.type !== "all" && filter.value).map(filter => filter.value);
const allFilter = attendeeFilters.filters.find((filter) => filter.type === "all");
isEveryoneFilterActive = (allFilter && allFilter.active) || false;
attendeeIds = attendeeFilters.filters
.filter((filter) => filter.type !== "all" && filter.value)
.map((filter) => filter.value);
}
data.attendees = await this.orm.call(
"res.partner",
"get_attendee_detail",
[attendeeIds, eventIds],
);
const currentPartnerId = this.user.partnerId;
if (!isEveryoneFilterActive) {
const activeAttendeeIds = new Set(attendeeFilters.filters
.filter(filter => filter.type !== "all" && filter.value && filter.active)
.map(filter => filter.value)
data.attendees = await this.orm.call("res.partner", "get_attendee_detail", [
attendeeIds,
eventIds,
]);
const currentPartnerId = user.partnerId;
if (!isEveryoneFilterActive && attendeeFilters) {
const activeAttendeeIds = new Set(
attendeeFilters.filters
.filter((filter) => filter.type !== "all" && filter.value && filter.active)
.map((filter) => filter.value)
);
// Duplicate records per attendee
const newRecords = {};
let duplicatedRecordIdx = -1;
for (const event of Object.values(data.records)) {
const eventData = event.rawRecord;
const attendees = eventData.partner_ids && eventData.partner_ids.length ? eventData.partner_ids : [eventData.partner_id[0]];
const attendees =
eventData.partner_ids && eventData.partner_ids.length
? eventData.partner_ids
: [eventData.partner_id[0]];
let duplicatedRecords = 0;
for (const attendee of attendees) {
if (!activeAttendeeIds.has(attendee)) {
@ -94,10 +140,9 @@ export class AttendeeCalendarModel extends CalendarModel {
}
// Records will share the same rawRecord.
const record = { ...event };
const attendeeInfo = data.attendees.find(a => (
a.id === attendee &&
a.event_id === event.id
));
const attendeeInfo = data.attendees.find(
(a) => a.id === attendee && a.event_id === event.id
);
record.attendeeId = attendee;
// Colors are linked to the partner_id but in this case we want it linked
// to attendeeId
@ -119,11 +164,10 @@ export class AttendeeCalendarModel extends CalendarModel {
} else {
for (const event of Object.values(data.records)) {
const eventData = event.rawRecord;
event.attendeeId = eventData.partner_id && eventData.partner_id[0]
const attendeeInfo = data.attendees.find(a => (
a.id === currentPartnerId &&
a.event_id === event.id
));
event.attendeeId = eventData.partner_id && eventData.partner_id[0];
const attendeeInfo = data.attendees.find(
(a) => a.id === currentPartnerId && a.event_id === event.id
);
if (attendeeInfo) {
event.isAlone = attendeeInfo.is_alone;
event.calendarAttendeeId = attendeeInfo.attendee_id;
@ -145,12 +189,14 @@ export class AttendeeCalendarModel extends CalendarModel {
} else {
const confirm = await new Promise((resolve) => {
this.dialog.add(ConfirmationDialog, {
body: this.env._t("Are you sure you want to delete this record ?"),
title: _t("Bye-bye, record!"),
body: deleteConfirmationMessage,
confirm: resolve.bind(null, true),
}, {
onClose: resolve.bind(null, false),
confirmLabel: _t("Delete"),
cancel: () => resolve.bind(null, false),
cancelLabel: _t("No, keep it"),
});
})
});
if (!confirm) {
return;
}
@ -160,19 +206,18 @@ export class AttendeeCalendarModel extends CalendarModel {
async _archiveRecord(id, recurrenceUpdate) {
if (!recurrenceUpdate && recurrenceUpdate !== "self_only") {
await this.orm.call(
this.resModel,
"action_archive",
[[id]],
);
await this.orm.call(this.resModel, "action_archive", [[id]]);
} else {
await this.orm.call(
this.resModel,
"action_mass_archive",
[[id], recurrenceUpdate],
);
await this.orm.call(this.resModel, "action_mass_archive", [[id], recurrenceUpdate]);
}
await this.load();
}
normalizeRecord(rawRecord) {
const normalizedRecord = super.normalizeRecord(rawRecord);
if (rawRecord.effective_privacy === "private") {
normalizedRecord.titleIcon = "fa fa-lock";
}
return normalizedRecord;
}
}
AttendeeCalendarModel.services = [...CalendarModel.services, "dialog", "orm"];

View file

@ -1,14 +1,13 @@
/** @odoo-module **/
import { CalendarRenderer } from "@web/views/calendar/calendar_renderer";
import { AttendeeCalendarCommonRenderer } from "@calendar/views/attendee_calendar/common/attendee_calendar_common_renderer";
import { AttendeeCalendarYearRenderer } from "@calendar/views/attendee_calendar/year/attendee_calendar_year_renderer";
export class AttendeeCalendarRenderer extends CalendarRenderer {}
AttendeeCalendarRenderer.components = {
...CalendarRenderer.components,
day: AttendeeCalendarCommonRenderer,
week: AttendeeCalendarCommonRenderer,
month: AttendeeCalendarCommonRenderer,
year: AttendeeCalendarYearRenderer,
};
export class AttendeeCalendarRenderer extends CalendarRenderer {
static components = {
...CalendarRenderer.components,
day: AttendeeCalendarCommonRenderer,
week: AttendeeCalendarCommonRenderer,
month: AttendeeCalendarCommonRenderer,
year: AttendeeCalendarYearRenderer,
};
}

View file

@ -1,5 +1,3 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { calendarView } from "@web/views/calendar/calendar_view";
import { AttendeeCalendarController } from "@calendar/views/attendee_calendar/attendee_calendar_controller";
@ -11,7 +9,6 @@ export const attendeeCalendarView = {
Controller: AttendeeCalendarController,
Model: AttendeeCalendarModel,
Renderer: AttendeeCalendarRenderer,
buttonTemplate: "calendar.AttendeeCalendarController.controlButtons",
};
registry.category("views").add("attendee_calendar", attendeeCalendarView);

View file

@ -1,27 +1,46 @@
/** @odoo-module **/
import { onWillStart } from "@odoo/owl";
import { CalendarCommonPopover } from "@web/views/calendar/calendar_common/calendar_common_popover";
import { useService } from "@web/core/utils/hooks";
import { useAskRecurrenceUpdatePolicy } from "@calendar/views/ask_recurrence_update_policy_hook";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { user } from "@web/core/user";
export class AttendeeCalendarCommonPopover extends CalendarCommonPopover {
static components = {
...CalendarCommonPopover.components,
Dropdown,
DropdownItem,
};
static subTemplates = {
...CalendarCommonPopover.subTemplates,
body: "calendar.AttendeeCalendarCommonPopover.body",
footer: "calendar.AttendeeCalendarCommonPopover.footer",
};
setup() {
super.setup();
this.user = useService("user");
this.orm = useService("orm");
this.actionService = useService("action");
this.askRecurrenceUpdatePolicy = useAskRecurrenceUpdatePolicy();
onWillStart(this.onWillStart);
}
async onWillStart() {
// Show status dropdown if user is in attendees list
if (this.isCurrentUserAttendee) {
if (this.isEventEditable) {
const stateSelections = await this.env.services.orm.call(
this.props.model.resModel,
"get_state_selections"
);
this.statusColors = {
accepted: "text-success",
declined: "text-danger",
tentative: "text-muted",
needsAction: "text-dark",
needsAction: "false",
};
this.statusInfo = {};
for (const selection of this.props.model.fields.attendee_status.selection) {
for (const selection of stateSelections) {
this.statusInfo[selection[0]] = {
text: selection[1],
color: this.statusColors[selection[0]],
@ -32,11 +51,14 @@ export class AttendeeCalendarCommonPopover extends CalendarCommonPopover {
}
get isCurrentUserAttendee() {
return this.props.record.rawRecord.partner_ids.includes(this.user.partnerId);
return (
this.props.record.rawRecord.partner_ids.includes(user.partnerId) ||
this.props.record.rawRecord.partner_id[0] === user.partnerId
);
}
get isCurrentUserOrganizer() {
return this.props.record.rawRecord.partner_id[0] === this.user.partnerId;
return this.props.record.rawRecord.partner_id[0] === user.partnerId;
}
get isEventPrivate() {
@ -45,31 +67,49 @@ export class AttendeeCalendarCommonPopover extends CalendarCommonPopover {
get displayAttendeeAnswerChoice() {
return (
this.props.record.rawRecord.partner_ids.some((partner) => partner !== this.user.partnerId) &&
this.props.record.rawRecord.partner_ids.some((partner) => partner !== user.partnerId) &&
this.props.record.isCurrentPartner
);
}
get isEventDetailsVisible() {
return this.isEventPrivate ? this.isCurrentUserAttendee : true;
return this.isEventPrivate ? this.isEventEditable : true;
}
get isEventArchivable() {
return false;
}
async onClickOpenRecord() {
const action = await this.orm.call("calendar.event", "action_open_calendar_event", [
this.props.record.id,
]);
this.actionService.doAction(action);
}
/**
* @override
*/
get isEventDeletable() {
return super.isEventDeletable && (this.isCurrentUserAttendee || this.isCurrentUserOrganizer) && !this.isEventArchivable;
return super.isEventDeletable && this.isEventEditable && !this.isEventArchivable;
}
/**
* @override
*/
get isEventEditable() {
return this.isEventPrivate ? this.isCurrentUserAttendee || this.isCurrentUserOrganizer : super.isEventEditable;
return this.props.record.rawRecord.user_can_edit;
}
get isEventViewable() {
return this.isEventPrivate ? this.isEventEditable : super.isEventEditable;
}
/**
* @override
*/
get hasFooter() {
return this.isEventViewable || super.hasFooter;
}
async changeAttendeeStatus(selectedStatus) {
@ -84,11 +124,11 @@ export class AttendeeCalendarCommonPopover extends CalendarCommonPopover {
return this.props.close();
}
}
await this.env.services.orm.call(
this.props.model.resModel,
"change_attendee_status",
[[record.id], selectedStatus, recurrenceUpdate],
);
await this.env.services.orm.call(this.props.model.resModel, "change_attendee_status", [
[record.id],
selectedStatus,
recurrenceUpdate,
]);
await this.props.model.load();
this.props.close();
}
@ -98,13 +138,3 @@ export class AttendeeCalendarCommonPopover extends CalendarCommonPopover {
await this.props.model.archiveRecord(this.props.record);
}
}
AttendeeCalendarCommonPopover.components = {
...CalendarCommonPopover.components,
Dropdown,
DropdownItem,
};
AttendeeCalendarCommonPopover.subTemplates = {
...CalendarCommonPopover.subTemplates,
body: "calendar.AttendeeCalendarCommonPopover.body",
footer: "calendar.AttendeeCalendarCommonPopover.footer",
};

View file

@ -1,30 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.AttendeeCalendarCommonPopover.body" t-inherit="web.CalendarCommonPopover.body" t-inherit-mode="primary" owl="1">
<t t-name="calendar.AttendeeCalendarCommonPopover.body" t-inherit="web.CalendarCommonPopover.body" t-inherit-mode="primary">
<xpath expr="//ul[hasclass('o_cw_popover_fields_secondary')]" position="attributes">
<attribute name="t-if">isEventDetailsVisible</attribute>
</xpath>
<xpath expr="//div[hasclass('flex-grow-1')]" position="attributes">
<attribute name="t-if">!fieldInfo.options.shouldOpenRecord</attribute>
</xpath>
<xpath expr="//div[hasclass('flex-grow-1')]" position="after">
<t t-if="fieldInfo.options.shouldOpenRecord">
<a href="#" t-on-click="onClickOpenRecord">
<t t-out="props.record.rawRecord.res_model_name"/>
</a>
</t>
</xpath>
</t>
<t t-name="calendar.AttendeeCalendarCommonPopover.footer" t-inherit="web.CalendarCommonPopover.footer" t-inherit-mode="primary" owl="1">
<t t-name="calendar.AttendeeCalendarCommonPopover.footer" t-inherit="web.CalendarCommonPopover.footer" t-inherit-mode="primary">
<xpath expr="//t[@t-if='isEventDeletable']" position="after">
<a t-if="isEventArchivable and isEventDetailsVisible" href="#" class="btn btn-secondary o_cw_popover_archive_g" t-on-click="onClickArchive">Delete</a>
<div t-if="displayAttendeeAnswerChoice" class="d-inline-block">
<Dropdown togglerClass="'btn btn-secondary'">
<t t-set-slot="toggler">
<i t-attf-class="fa fa-circle o-calendar-attendee-status-icon #{selectedStatusInfo.color}"/> <span class="o-calendar-attendee-status-text" t-esc="selectedStatusInfo.text"></span>
</t>
<DropdownItem onSelected="() => this.changeAttendeeStatus('accepted')">
<i class="fa fa-circle text-success"/> Accept
</DropdownItem>
<DropdownItem onSelected="() => this.changeAttendeeStatus('declined')">
<i class="fa fa-circle text-danger"/> Decline
</DropdownItem>
<DropdownItem onSelected="() => this.changeAttendeeStatus('tentative')">
<i class="fa fa-circle text-muted"/> Uncertain
</DropdownItem>
</Dropdown>
</div>
<a t-if="isEventArchivable and isEventDetailsVisible" href="#" class="btn btn-secondary" t-on-click="onClickArchive">
<i class="fa fa-trash"/>
</a>
</xpath>
<xpath expr="//t[@t-if='isEventDeletable']" position="before">
<t t-if="displayAttendeeAnswerChoice">
<div class="btn-group w-100 w-lg-auto ms-lg-auto order-first order-lg-0 px-2 px-lg-0">
<button class="btn"
t-attf-class="#{selectedStatusInfo.text === 'Yes' ? 'btn-secondary active' : 'btn-outline-secondary'}"
t-on-click="() => this.changeAttendeeStatus('accepted')">Yes</button>
<button class="btn"
t-attf-class="#{selectedStatusInfo.text === 'No' ? 'btn-secondary active' : 'btn-outline-secondary'}"
t-on-click="() => this.changeAttendeeStatus('declined')">No</button>
<button class="btn"
t-attf-class="#{selectedStatusInfo.text === 'Maybe' ? 'btn-secondary active' : 'btn-outline-secondary'}"
t-on-click="() => this.changeAttendeeStatus('tentative')">Maybe</button>
</div>
</t>
</xpath>
</t>
</templates>

View file

@ -1,37 +1,57 @@
/** @odoo-module **/
import { CalendarCommonRenderer } from "@web/views/calendar/calendar_common/calendar_common_renderer";
import { AttendeeCalendarCommonPopover } from "@calendar/views/attendee_calendar/common/attendee_calendar_common_popover";
export class AttendeeCalendarCommonRenderer extends CalendarCommonRenderer {
static eventTemplate = "calendar.AttendeeCalendarCommonRenderer.event";
static components = {
...CalendarCommonRenderer.components,
Popover: AttendeeCalendarCommonPopover,
};
/**
* @override
*
* Give a new key to our fc records to be able to iterate through in templates
*/
convertRecordToEvent(record) {
let editable = false;
if (record && record.rawRecord) {
editable = record.rawRecord.user_can_edit;
}
return {
...super.convertRecordToEvent(record),
id: record._recordId || record.id,
editable: editable,
};
}
/**
* @override
*/
onEventRender(info) {
super.onEventRender(...arguments);
const { el, event } = info;
eventClassNames({ el, event }) {
const classesToAdd = super.eventClassNames(...arguments);
const record = this.props.model.records[event.id];
if (record) {
if (record.rawRecord.is_highlighted) {
el.classList.add("o_event_highlight");
classesToAdd.push("o_event_highlight");
}
if (record.isAlone) {
el.classList.add("o_attendee_status_alone");
classesToAdd.push("o_attendee_status_alone");
} else {
el.classList.add(`o_attendee_status_${record.attendeeStatus}`);
classesToAdd.push(`o_attendee_status_${record.attendeeStatus}`);
}
}
return classesToAdd;
}
/**
* @override
*/
onEventDidMount({ el, event }) {
super.onEventDidMount(...arguments);
const record = this.props.model.records[event.id];
if (record) {
if (this.env.searchModel?.context?.default_calendar_event_id === parseInt(event.id)) {
this.openPopover(el, record);
}
}
}
@ -45,8 +65,3 @@ export class AttendeeCalendarCommonRenderer extends CalendarCommonRenderer {
return true;
}
}
AttendeeCalendarCommonRenderer.eventTemplate = "calendar.AttendeeCalendarCommonRenderer.event";
AttendeeCalendarCommonRenderer.components = {
...CalendarCommonRenderer.components,
Popover: AttendeeCalendarCommonPopover,
};

View file

@ -1,12 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.AttendeeCalendarCommonRenderer.event" owl="1">
<div class="fc-content">
<div t-attf-class="#{attendeeStatus != 'accepted' ? 'fw-normal' : ''} o_event_title me-2">
<span t-if="isAlone" class="fa fa-exclamation-circle"/>
<t t-esc="title"/>
<t t-name="calendar.AttendeeCalendarCommonRenderer.event">
<span t-if="!isTimeHidden and isMonth" class="d-none d-lg-inline fc-time fw-normal" t-esc="startTime" />
<div t-attf-class="o_event_title text-truncate">
<i t-if="isAlone" class="fa fa-exclamation-circle align-self-top me-1" data-tooltip="You're alone in this meeting"/>
<span t-esc="title" t-attf-class="#{(attendeeStatus == 'accepted' || rawRecord.is_highlighted) ? 'fw-bolder' : ''}"/>
<t t-if="!isAllDay and !isMonth and !isSmall">
<t t-if="24 > duration and duration > 0.5">
<br/><span class="fw-normal mt-1"><t t-esc="startTime"/> - <t t-esc="endTime"/></span>
</t>
<t t-else="">
, <t t-esc="startTime"/>
</t>
</t>
</div>
<span t-if="!isTimeHidden" class="fc-time" t-esc="startTime" />
</div>
</t>
</templates>

View file

@ -1,8 +1,10 @@
/** @odoo-module **/
import { CalendarYearPopover } from "@web/views/calendar/calendar_year/calendar_year_popover";
export class AttendeeCalendarYearPopover extends CalendarYearPopover {
static subTemplates = {
...CalendarYearPopover.subTemplates,
body: "calendar.AttendeeCalendarYearPopover.body",
};
getRecordClass(record) {
const classes = [super.getRecordClass(record)];
if (record.isAlone) {
@ -13,7 +15,3 @@ export class AttendeeCalendarYearPopover extends CalendarYearPopover {
return classes.join(" ");
}
}
AttendeeCalendarYearPopover.subTemplates = {
...CalendarYearPopover.subTemplates,
body: "calendar.AttendeeCalendarYearPopover.body",
};

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.AttendeeCalendarYearPopover.body" t-inherit="web.CalendarYearPopover.body" t-inherit-mode="primary" owl="1">
<t t-name="calendar.AttendeeCalendarYearPopover.body" t-inherit="web.CalendarYearPopover.body" t-inherit-mode="primary">
<!-- Since we duplicate the event this is needed to avoid duplicated key errors -->
<xpath expr="//t[@t-foreach='recordGroup.records']" position="attributes">
<attribute name="t-key">record._recordId</attribute>

View file

@ -1,10 +1,9 @@
/** @odoo-module **/
import { CalendarYearRenderer } from "@web/views/calendar/calendar_year/calendar_year_renderer";
import { AttendeeCalendarYearPopover } from "@calendar/views/attendee_calendar/year/attendee_calendar_year_popover";
export class AttendeeCalendarYearRenderer extends CalendarYearRenderer {}
AttendeeCalendarYearRenderer.components = {
...CalendarYearRenderer,
Popover: AttendeeCalendarYearPopover,
};
export class AttendeeCalendarYearRenderer extends CalendarYearRenderer {
static components = {
...CalendarYearRenderer.components,
Popover: AttendeeCalendarYearPopover,
};
}

View file

@ -1,44 +1,76 @@
/** @odoo-module **/
import { FormController } from "@web/views/form/form_controller";
import { useAskRecurrenceUpdatePolicy } from "@calendar/views/ask_recurrence_update_policy_hook";
import { useService } from "@web/core/utils/hooks";
const { onWillStart } = owl;
export class CalendarFormController extends FormController {
setup() {
super.setup();
const ormService = useService("orm");
onWillStart(async () => {
this.discussVideocallLocation = await ormService.call(
"calendar.event",
"get_discuss_videocall_location",
);
});
this.actionService = useService("action");
this.askRecurrenceUpdatePolicy = useAskRecurrenceUpdatePolicy();
}
/**
* @override
*/
async beforeExecuteActionButton(clickParams) {
const action = clickParams.name;
if (action == 'clear_videocall_location' || action === 'set_discuss_videocall_location') {
let newVal = false;
let videoCallSource = 'custom'
let changes = {};
if (action === 'set_discuss_videocall_location') {
newVal = this.discussVideocallLocation;
videoCallSource = 'discuss';
changes.access_token = this.discussVideocallLocation.split('/').pop();
}
changes = Object.assign(changes, {
videocall_location: newVal,
videocall_source: videoCallSource,
});
this.model.root.update(changes);
return false; // no continue
if (action === "clear_videocall_location") {
this.model.root.clearLocation();
return false;
} else if (action === "set_discuss_videocall_location") {
this.model.root.setLocation();
return false;
}
return super.beforeExecuteActionButton(...arguments);
}
/**
* Custom delete function for calendar events, which can call the unlink action or not.
* When there is only one attendee, who is also the organizer, and the organizer is not listed in the current attendees, it performs the default delete.
* Otherwise, it calls the unlink action on the server.
*/
async deleteRecord() {
const record = this.model.root;
const rootValues = record._values;
let recurrenceUpdate = false;
if (record.data.recurrency) {
recurrenceUpdate = await this.askRecurrenceUpdatePolicy();
}
if (rootValues.attendees_count == 1 && rootValues.user_id.id !== rootValues.partner_ids._currentIds[0]) {
await this._archiveRecord(record.resId, recurrenceUpdate);
} else {
await this.orm.call("calendar.event", "action_unlink_event", [
this.model.root.resId,
this.model.root.data.partner_ids.resIds,
this.model.root.data.recurrence_update,
])
.then((action) => {
if (action && action.context) {
this.actionService.doAction(action);
} else {
this.actionService.doAction({
type: "ir.actions.act_window",
name: "Meetings",
res_model: "calendar.event",
view_mode: "calendar",
views: [[false, "calendar"]],
target: "current",
});
}
});
}
}
/**
* Archives a calendar event record.
*
* @param {number} id - The ID of the record to archive.
* @param {boolean} recurrenceUpdate - Indicates how the archive of a recurring event will be updated.
*/
async _archiveRecord(id, recurrenceUpdate) {
await this.orm.call(this.model.root.resModel, "action_mass_archive", [
[id], recurrenceUpdate
]);
this.env.config.historyBack();
}
}

View file

@ -0,0 +1,37 @@
import { onWillStart } from "@odoo/owl";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { Record } from "@web/model/relational_model/record";
class CalendarFormRecord extends Record {
async setLocation() {
const videoLocation = await this.model.discussVideocallLocation;
this.update({
access_token: videoLocation.split("/").pop(),
videocall_location: videoLocation,
videocall_source: "discuss",
});
}
async clearLocation() {
this.update({
videocall_location: false,
videocall_source: "custom",
});
}
}
export class CalendarFormModel extends RelationalModel {
static Record = CalendarFormRecord;
setup() {
super.setup(...arguments);
onWillStart(async () => {
this.discussVideocallLocation = this.orm.call(
"calendar.event",
"get_discuss_videocall_location"
);
});
}
}

View file

@ -1,12 +1,12 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
import { CalendarFormController } from "@calendar/views/calendar_form/calendar_form_controller";
import { CalendarFormModel } from "@calendar/views/calendar_form/calendar_form_model";
export const CalendarFormView = {
...formView,
Controller: CalendarFormController,
Model: CalendarFormModel,
};
registry.category("views").add("calendar_form", CalendarFormView);

View file

@ -0,0 +1,90 @@
import { registry } from "@web/core/registry";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { CalendarFormView } from "./calendar_form_view";
import { CalendarFormController } from "./calendar_form_controller";
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
export const QUICK_CREATE_CALENDAR_EVENT_FIELDS = {
name: { type: "string" },
start: { type: "datetime" },
start_date: { type: "date" },
stop_date: { type: "date" },
stop: { type: "datetime" },
allday: { type: "boolean" },
partner_ids: { type: "many2many" },
privacy: { type: "selection" },
location: { type: "string" },
videocall_location: { type: "string" },
notes: { type: "string" }
};
function getDefaultValuesFromRecord(data) {
const context = {};
for (const fieldName in QUICK_CREATE_CALENDAR_EVENT_FIELDS) {
if (fieldName in data) {
let value = data[fieldName];
const { type } = QUICK_CREATE_CALENDAR_EVENT_FIELDS[fieldName]
if (type === 'many2many') {
value = value.records.map((record) => record.resId);
} else if (type === 'date') {
value = value && serializeDate(value);
} else if (type === "datetime") {
value = value && serializeDateTime(value);
}
context[`default_${fieldName}`] = value || false;
}
}
return context;
}
export class CalendarQuickCreateFormController extends CalendarFormController {
goToFullEvent() {
const context = getDefaultValuesFromRecord(this.model.root.data);
return this.actionService.doAction(
{
type: "ir.actions.act_window",
res_model: "calendar.event",
views: [[false, "form"]],
res_id: this.model.root.resId || false,
},
{
additionalContext: {
...this.props.context,
...context,
},
}
);
}
/**
* This override makes it so that, after creating a calendar event through the activity buttons/widget
* on a record, the user is redirected back to the record they clicked the activity button on.
*/
async onRecordSaved() {
await super.onRecordSaved(arguments);
if (this.props.context.return_to_parent_breadcrumb) {
const breadcrumb = this.actionService.currentController.config.breadcrumbs.at(-2);
if (breadcrumb) {
// todo guce postfreeze: make safer (knowledge macro system?)
breadcrumb.onSelected();
}
}
}
}
registry.category("views").add("calendar_quick_create_form_view", {
...CalendarFormView,
Controller: CalendarQuickCreateFormController,
});
export class CalendarQuickCreate extends FormViewDialog {
setup() {
super.setup();
Object.assign(this.viewProps, {
...this.viewProps,
buttonTemplate: "calendar.CalendarQuickCreateButtons",
});
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="calendar.CalendarQuickCreateButtons" t-inherit="web.FormViewDialog.ToOne.buttons">
<xpath expr="//button[hasclass('o_form_button_save')]" position="after">
<button t-if="!props.resId" class="btn btn-secondary" t-on-click="goToFullEvent">More Options</button>
</xpath>
<xpath expr="//button[hasclass('o_form_button_cancel')]" position="attributes">
<attribute name="class" add="ms-auto" separator=" "/>
</xpath>
</t>
</templates>

View file

@ -1,71 +0,0 @@
/** @odoo-module **/
import { FormRenderer } from "@web/views/form/form_renderer";
import { session } from "@web/session";
import { useService } from "@web/core/utils/hooks";
const providerData = {
'google': {
'restart_sync_method': 'restart_google_synchronization',
'sync_route': '/google_calendar/sync_data'
},
'microsoft': {
'restart_sync_method': 'restart_microsoft_synchronization',
'sync_route': '/microsoft_calendar/sync_data'
}
}
export class CalendarProviderConfigFormRenderer extends FormRenderer {
setup() {
super.setup();
this.orm = useService('orm');
this.rpc = useService('rpc');
}
/**
* Activate the external sync for the first time, after installing the
* relevant submodule if necessary.
*
* @private
*/
async onConnect(ev) {
ev.preventDefault();
ev.stopImmediatePropagation();
if (!await this.props.record.save({stayInEdition: true})) {
return; // handled by view
}
await this.orm.call(
this.props.record.resModel,
'action_calendar_prepare_external_provider_sync',
[this.props.record.resId]
)
// See google/microsoft_calendar for the origin of this shortened version
const { restart_sync_method, sync_route } = providerData[this.props.record.data.external_calendar_provider];
await this.orm.call(
'res.users',
restart_sync_method,
[[session.uid]]
);
const response = await this.rpc(
sync_route, {
model: 'calendar.event',
fromurl: window.location.href,
}
);
await this._beforeLeaveContext();
if (response.status === "need_auth") {
window.location.assign(response.url);
} else if (response.status === "need_refresh") {
window.location.reload();
}
}
/**
* Hook to perform additional work before redirecting to external url or reloading.
*
* @private
*/
async _beforeLeaveContext() {
return Promise.resolve();
}
}

View file

@ -1,13 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
import { CalendarProviderConfigFormRenderer } from "./calendar_provider_config_form_renderer";
export const CalendarProviderConfigFormView = {
...formView,
Renderer: CalendarProviderConfigFormRenderer,
};
registry.category("views").add("calendar_provider_config_form", CalendarProviderConfigFormView);

View file

@ -0,0 +1,5 @@
import { TagsList } from "@web/core/tags_list/tags_list";
export class AttendeeTagsList extends TagsList {
static template = "calendar.AttendeeTagsList";
}

View file

@ -0,0 +1,24 @@
.o_attendee_tags_list {
--Avatar-size: 24px;
position: relative;
.attendee_tag_status {
@include o-position-absolute($bottom: -0.375em, $right: -0.375em);
aspect-ratio: 1;
border: 1px $o-view-background-color solid;
}
.o_attendee_status_accepted {
background-color: tint-color($success, 80%);
color: $success;
}
.o_attendee_status_declined {
background-color: tint-color($danger, 80%);
color: $danger;
}
.o_attendee_status_tentative {
background-color: $gray-200;
color: $dark;
}
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.AttendeeTagsList" t-inherit="web.TagsList" t-inherit-mode="primary">
<xpath expr="//span[hasclass('o_avatar_backdrop')]" position="attributes">
<attribute name="class" remove="opacity-100-hover" separator=" "/>
</xpath>
<xpath expr="//img[hasclass('o_m2m_avatar')]" position="replace">
<div class="o_attendee_tags_list">
<img t-if="tag.img"
t-att-src="tag.img"
class="o_avatar o_m2m_avatar position-relative rounded"
t-att-class="tag.imageClass"/>
<div t-if="tag.status &amp;&amp; tag.statusIcon" t-attf-class="attendee_tag_status o_attendee_status_{{tag.status}} fa fa-fw {{tag.statusIcon}} rounded-circle"/>
</div>
</xpath>
<xpath expr="//div[hasclass('o_tag_badge_text')]" position="after">
<div t-if="tag.isUnavailable" title="unavailable" class="ms-1">
<i class="fa fa-moon-o position-relative" role="img"/>
</div>
<div t-if="tag.noEmail" title="no email" class="ms-1">
<i class="fa fa-exclamation-triangle position-relative" role="img"/>
</div>
</xpath>
<xpath expr="//span[hasclass('o_m2m_avatar_empty')]" position="attributes">
<attribute name="class" remove="text-center" separator=" "/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,14 @@
import { registry } from "@web/core/registry";
import { htmlField, HtmlField } from "@html_editor/fields/html_field";
import { MoveNodePlugin } from "@html_editor/main/movenode_plugin";
class CalendarEventNotesHtmlField extends HtmlField {
getConfig() {
const config = super.getConfig();
config.Plugins = config.Plugins.filter((plugin) => plugin !== MoveNodePlugin);
return config;
}
}
export const calendarEventNotesHtmlField = { ...htmlField, component: CalendarEventNotesHtmlField };
registry.category("fields").add("calendar_event_notes_html", calendarEventNotesHtmlField);

View file

@ -1,15 +1,63 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Many2ManyTagsAvatarField } from "@web/views/fields/many2many_tags_avatar/many2many_tags_avatar_field";
import {
Many2ManyTagsAvatarField,
many2ManyTagsAvatarField,
} from "@web/views/fields/many2many_tags_avatar/many2many_tags_avatar_field";
import { useSpecialData } from "@web/views/fields/relational_utils";
import { AttendeeTagsList } from "@calendar/views/fields/attendee_tags_list";
const ICON_BY_STATUS = {
accepted: "fa-check",
declined: "fa-times",
tentative: "fa-question",
};
export class Many2ManyAttendee extends Many2ManyTagsAvatarField {
static template = "calendar.Many2ManyAttendee";
static components = {
...Many2ManyAttendee.components,
TagsList: AttendeeTagsList,
};
setup() {
super.setup();
this.specialData = useSpecialData((orm, props) => {
const { context, name, record } = props;
return orm.call(
"res.partner",
"get_attendee_detail",
[record.data[name].records.map((rec) => rec.resId), [record.resId || false]],
{
context,
}
);
});
}
get tags() {
const { partner_ids: partnerIds } = this.props.record.preloadedData;
const partnerIds = this.specialData.data;
const noEmailPartnerIds = this.props.record.data.invalid_email_partner_ids
? this.props.record.data.invalid_email_partner_ids.records
: [];
const unavailablePartnerIds = this.props.record.data.unavailable_partner_ids
? this.props.record.data.unavailable_partner_ids.records
: [];
const tags = super.tags.map((tag) => {
const partner = partnerIds.find((partner) => tag.resId === partner.id);
const noEmail = noEmailPartnerIds.find(
(noEmailPartner) => tag.resId == noEmailPartner.resId
);
if (partner) {
tag.className = `o_attendee_border o_attendee_border_${partner.status}`;
tag.status = partner.status;
tag.statusIcon = ICON_BY_STATUS[partner.status];
}
if (noEmail) {
tag.noEmail = true;
}
if (
unavailablePartnerIds.find(
(unavailablePartner) => tag.resId == unavailablePartner.resId
)
) {
tag.isUnavailable = true;
}
return tag;
});
@ -26,24 +74,11 @@ export class Many2ManyAttendee extends Many2ManyTagsAvatarField {
return tags;
}
}
Many2ManyAttendee.additionalClasses = ["o_field_many2many_tags_avatar"];
Many2ManyAttendee.legacySpecialData = "_fetchSpecialAttendeeStatus";
registry.category("fields").add("many2manyattendee", Many2ManyAttendee);
export const many2ManyAttendee = {
...many2ManyTagsAvatarField,
component: Many2ManyAttendee,
additionalClasses: ["o_field_many2many_tags_avatar", "w-100"],
};
export function preloadMany2ManyAttendee(orm, record, fieldName) {
const context = record.getFieldContext(fieldName);
return orm.call(
"res.partner",
"get_attendee_detail",
[record.data[fieldName].records.map(rec => rec.resId), [record.resId || false]],
{
context,
},
);
}
registry.category("preloadedData").add("many2manyattendee", {
loadOnTypes: ["many2many"],
preload: preloadMany2ManyAttendee,
});
registry.category("fields").add("many2manyattendee", many2ManyAttendee);

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.Many2ManyAttendee" t-inherit="web.Many2ManyTagsAvatarField" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_field_many2many_selection')]" position="attributes">
<attribute name="style" add="flex-basis: 100%;" separator=" "/>
</xpath>
<xpath expr="//Many2XAutocomplete" position="attributes">
<attribute name="placeholder">props.placeholder</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,27 @@
import { Many2ManyAttendee, many2ManyAttendee } from "@calendar/views/fields/many2many_attendee";
import { useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class Many2ManyAttendeeExpandable extends Many2ManyAttendee {
static template = "calendar.Many2ManyAttendeeExpandable";
state = useState({ expanded: false });
setup() {
super.setup();
this.attendeesCount = this.props.record.data.attendees_count;
this.acceptedCount = this.props.record.data.accepted_count;
this.declinedCount = this.props.record.data.declined_count;
this.uncertainCount = this.attendeesCount - this.acceptedCount - this.declinedCount;
}
onExpanderClick() {
this.state.expanded = !this.state.expanded;
}
}
export const many2ManyAttendeeExpandable = {
...many2ManyAttendee,
component: Many2ManyAttendeeExpandable,
};
registry.category("fields").add("many2manyattendeeexpandable", many2ManyAttendeeExpandable);

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="calendar.Many2ManyAttendeeExpandable" t-inherit="web.Many2ManyTagsAvatarField" t-inherit-mode="primary">
<xpath expr="//div[hasclass('many2many_tags_avatar_field_container')]" position="attributes">
<attribute name="class" remove="mw-100" add="w-100" separator=" "/>
</xpath>
<xpath expr="//TagsList" position="attributes">
<attribute name="visibleItemsLimit">state.expanded ? tags.length : 5</attribute>
</xpath>
<xpath expr="//TagsList" position="before">
<t t-if="tags.length > 5">
<div class="d-flex w-100">
<div class="flex-grow-1">
<strong><t t-out="attendeesCount"/> attendees</strong>
<br/><t t-out="acceptedCount"/> accepted
<br/><t t-out="declinedCount"/> declined
<br/><t t-out="uncertainCount"/> uncertain
</div>
<i role="button" t-on-click="onExpanderClick" t-att-class="state.expanded ? 'oi oi-chevron-up' : 'oi oi-chevron-down'"></i>
</div>
</t>
</xpath>
<xpath expr="//TagsList" position="replace">
<t t-if="!state.expanded &amp;&amp; tags.length > 10"> </t>
<t t-else="">$0</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,37 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { ListController } from "@web/views/list/list_controller";
import { useAskRecurrenceUpdatePolicy } from "@calendar/views/ask_recurrence_update_policy_hook";
export class CaledarListController extends ListController {
setup() {
super.setup();
this.orm = useService("orm");
this.askRecurrenceUpdatePolicy = useAskRecurrenceUpdatePolicy();
}
get modelOptions() {
return {
...super.modelOptions,
lazy: false,
};
}
/**
* Deletes selected records with handling for recurring events.
*/
async onDeleteSelectedRecords() {
const selectedRecords = this.model.root.selection;
let recurrenceUpdate = false;
if (selectedRecords.length == 1 && selectedRecords[0]?.data.recurrency) {
recurrenceUpdate = await this.askRecurrenceUpdatePolicy();
if (recurrenceUpdate) {
await this.orm.call(this.model.root.resModel, "action_mass_archive", [[selectedRecords[0]?.resId], recurrenceUpdate]);
this.model.load();
}
} else {
super.onDeleteSelectedRecords(...arguments);
}
}
}

View file

@ -0,0 +1,47 @@
import { listView } from "@web/views/list/list_view";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { CaledarListController } from "./calendar_list_controller";
export class CalendarListModel extends listView.Model {
setup(params, { action, dialog, notification, rpc, user, view, company }) {
super.setup(...arguments);
}
/**
* @override
* Add the calendar view's selected attendees to the list view's domain.
*/
async load(params = {}) {
const filters = params?.context?.calendar_filters;
const emptyDomain = Array.isArray(params?.domain) && params.domain.length == 0;
if (filters && emptyDomain) {
const selectedPartnerIds = await this.orm.call(
"res.users",
"get_selected_calendars_partner_ids",
[[user.userId], filters["user"]]
);
// Filter attendees to be shown if 'everybody' filter isn't active.
if (!filters["all"]) {
params.domain.push(["partner_ids", "in", selectedPartnerIds]);
}
}
return super.load(params);
}
}
export const CalendarListView = {
...listView,
Model: CalendarListModel,
Controller: CaledarListController,
};
function _mockGetCalendarPartnerIds(params) {
/* Mock function for when there aren't records to be shown. */
return [];
}
registry.category("views").add("calendar_list_view", CalendarListView);
registry
.category("sample_server")
.add("get_selected_calendars_partner_ids", _mockGetCalendarPartnerIds);

View file

@ -0,0 +1,16 @@
import { registry } from "@web/core/registry";
import { WeekDays, weekDays } from "@web/views/widgets/week_days/week_days";
export class CalendarWeekDays extends WeekDays {
static template = "calendar.WeekDays";
onChange(day) {
this.props.record.update({ [day]: !this.data[day] });
}
};
export const calendarWeekDays = {
component: CalendarWeekDays,
fieldDependencies: weekDays.fieldDependencies,
};
registry.category("view_widgets").add("calendar_week_days", calendarWeekDays);

View file

@ -0,0 +1,10 @@
.o_calendar_week_days_rounded {
border-radius: 50%;
margin-right: 8px;
width: 24px;
height: 24px;
justify-content: center;
display: flex;
cursor: pointer;
align-items: center;
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.WeekDays">
<div class="o_recurrent_weekdays mb-2 d-flex">
<t t-foreach="weekdays" t-as="day" t-key="day">
<div t-attf-class="o_calendar_week_days_rounded {{data[day] ? 'text-bg-action' : 'btn-secondary'}}"
t-on-click="this.props.readonly? () => {} : () => this.onChange(day)">
<t t-esc="props.record.fields[day].string[0]"/>
</div>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,75 @@
import { defineCalendarModels } from "@calendar/../tests/calendar_test_helpers";
import {
click,
contains,
openFormView,
registerArchs,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { test } from "@odoo/hoot";
import { preloadBundle } from "@web/../tests/web_test_helpers";
defineCalendarModels();
preloadBundle("web.fullcalendar_lib");
test("activity click on Reschedule", async () => {
registerArchs({ "calendar.event,false,calendar": `<calendar date_start="start"/>` });
const pyEnv = await startServer();
const resPartnerId = pyEnv["res.partner"].create({});
const meetingActivityTypeId = pyEnv["mail.activity.type"].create({
icon: "fa-calendar",
name: "Meeting",
});
const calendarAttendeeId = pyEnv["calendar.attendee"].create({
partner_id: resPartnerId,
});
const calendaMeetingId = pyEnv["calendar.event"].create({
res_model: "calendar.event",
name: "meeting1",
start: "2022-07-06 06:30:00",
attendee_ids: [calendarAttendeeId],
});
pyEnv["mail.activity"].create({
name: "Small Meeting",
activity_type_id: meetingActivityTypeId,
can_write: true,
res_id: resPartnerId,
res_model: "res.partner",
calendar_event_id: calendaMeetingId,
});
await start();
await openFormView("res.partner", resPartnerId);
await click(".btn", { text: "Reschedule" });
await contains(".o_calendar_view");
});
test("Can cancel activity linked to an event", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Milan Kundera" });
const activityTypeId = pyEnv["mail.activity.type"].create({
icon: "fa-calendar",
name: "Meeting",
});
const attendeeId = pyEnv["calendar.attendee"].create({
partner_id: partnerId,
});
const calendaMeetingId = pyEnv["calendar.event"].create({
res_model: "calendar.event",
name: "meeting1",
start: "2022-07-06 06:30:00",
attendee_ids: [attendeeId],
});
pyEnv["mail.activity"].create({
name: "Small Meeting",
activity_type_id: activityTypeId,
can_write: true,
res_id: partnerId,
res_model: "res.partner",
calendar_event_id: calendaMeetingId,
});
await start();
await openFormView("res.partner", partnerId);
await click(".o-mail-Activity .btn", { text: "Cancel" });
await contains(".o-mail-Activity", { count: 0 });
});

View file

@ -0,0 +1,50 @@
import { defineCalendarModels } from "@calendar/../tests/calendar_test_helpers";
import { click, contains, start, startServer } from "@mail/../tests/mail_test_helpers";
import { test } from "@odoo/hoot";
import { mockDate } from "@odoo/hoot-mock";
import {
asyncStep,
mockService,
preloadBundle,
serverState,
waitForSteps,
} from "@web/../tests/web_test_helpers";
defineCalendarModels();
preloadBundle("web.fullcalendar_lib");
test("activity menu widget:today meetings", async () => {
mockDate(2018, 3, 20, 6, 0, 0);
const pyEnv = await startServer();
const attendeeId = pyEnv["calendar.attendee"].create({ partner_id: serverState.partnerId });
pyEnv["calendar.event"].create([
{
res_model: "calendar.event",
name: "meeting1",
start: "2018-04-20 06:30:00",
attendee_ids: [attendeeId],
},
{
res_model: "calendar.event",
name: "meeting2",
start: "2018-04-20 09:30:00",
attendee_ids: [attendeeId],
},
]);
mockService("action", {
doAction(action) {
if (typeof action === "string") {
asyncStep(action);
}
},
});
await start();
await contains(".o_menu_systray i[aria-label='Activities']");
await click(".o_menu_systray i[aria-label='Activities']");
await contains(".o-mail-ActivityGroup div[name='activityTitle']", { text: "Today's Meetings" });
await contains(".o-mail-ActivityGroup .o-calendar-meeting", { count: 2 });
await contains(".o-calendar-meeting span.fw-bold", { text: "meeting1" });
await contains(".o-calendar-meeting span:not(.fw-bold)", { text: "meeting2" });
await click(".o-mail-ActivityMenu .o-mail-ActivityGroup");
await waitForSteps(["calendar.action_calendar_event"]);
});

View file

@ -1,49 +0,0 @@
/** @odoo-module **/
import { start, startServer } from '@mail/../tests/helpers/test_utils';
QUnit.module('calendar', () => {
QUnit.module('components', () => {
QUnit.module('activity_tests.js');
QUnit.test('activity click on Reschedule', async function (assert) {
assert.expect(1);
const pyEnv = await startServer();
const resPartnerId = pyEnv['res.partner'].create({});
const meetingActivityTypeId = pyEnv['mail.activity.type'].create({ icon: 'fa-calendar', name: "Meeting" });
const calendarAttendeeId = pyEnv['calendar.attendee'].create({ partner_id: resPartnerId });
const calendaMeetingId = pyEnv['calendar.event'].create({
res_model: "calendar.event",
name: "meeting1",
start: "2022-07-06 06:30:00",
attendee_ids: [calendarAttendeeId],
});
pyEnv['mail.activity'].create({
name: "Small Meeting",
activity_type_id: meetingActivityTypeId,
can_write: true,
res_id: resPartnerId,
res_model: 'res.partner',
calendar_event_id: calendaMeetingId,
});
const { click, openFormView } = await start();
await openFormView(
{
res_model: 'res.partner',
res_id: resPartnerId,
},
);
await click('.o_Activity_editButton');
assert.containsOnce(
document.body,
'.o_calendar_view',
"should have opened calendar view"
);
});
});
});

View file

@ -0,0 +1,63 @@
import { defineCalendarModels } from "@calendar/../tests/calendar_test_helpers";
import {
click,
contains,
openListView,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { test } from "@odoo/hoot";
import { preloadBundle, serverState } from "@web/../tests/web_test_helpers";
import { serializeDateTime } from "@web/core/l10n/dates";
const { DateTime } = luxon;
defineCalendarModels();
preloadBundle("web.fullcalendar_lib");
test("list activity widget: reschedule button in dropdown", async () => {
const pyEnv = await startServer();
const resPartnerId = pyEnv["res.partner"].create({});
const activityTypeId = pyEnv["mail.activity.type"].create({
icon: "fa-calendar",
name: "Meeting",
});
const tomorrow = serializeDateTime(DateTime.now().plus({ days: 1 }));
const attendeeId = pyEnv["calendar.attendee"].create({ partner_id: resPartnerId });
const meetingId = pyEnv["calendar.event"].create({
res_model: "calendar.event",
name: "meeting1",
start: tomorrow,
attendee_ids: [attendeeId],
});
const activityId_1 = pyEnv["mail.activity"].create({
name: "OXP",
activity_type_id: activityTypeId,
date_deadline: tomorrow,
state: "planned",
can_write: true,
res_id: resPartnerId,
res_model: "res.partner",
calendar_event_id: meetingId,
summary: "OXP",
});
pyEnv["res.partner"].write([serverState.partnerId], {
activity_ids: [activityId_1],
activity_state: "today",
});
// FIXME: Manually trigger recomputation of related fields
pyEnv["res.users"]._applyComputesAndValidate();
pyEnv["res.users"][0].activity_ids = [activityId_1];
await start();
await openListView("res.users", {
arch: `<list>
<field name="activity_ids" widget="list_activity"/>
</list>`,
});
await contains(".o-mail-ListActivity-summary", { text: "OXP" });
await click(".o-mail-ActivityButton"); // open the popover
await contains(".o-mail-ActivityListPopoverItem-editbtn .fa-pencil", { count: 0 });
await contains(".o-mail-ActivityListPopoverItem-editbtn .fa-calendar");
});

View file

@ -0,0 +1,147 @@
import { defineCalendarModels } from "@calendar/../tests/calendar_test_helpers";
import { beforeEach, expect, test } from "@odoo/hoot";
import { mockDate } from "@odoo/hoot-mock";
import {
contains,
makeMockServer,
MockServer,
mountView,
onRpc,
preloadBundle,
serverState,
} from "@web/../tests/web_test_helpers";
import {
changeScale,
clickEvent,
expandCalendarView,
findDateColumn,
findTimeRow,
} from "@web/../tests/views/calendar/calendar_test_helpers";
defineCalendarModels();
preloadBundle("web.fullcalendar_lib");
const serverData = {};
const arch = /*xml*/ `
<calendar js_class="attendee_calendar"
event_open_popup="1"
date_start="start"
date_stop="stop"
all_day="allday"
mode="month"
>
<field name="partner_ids" options="{'block': True, 'icon': 'fa fa-users'}"
filters="1" widget="many2manyattendeeexpandable" write_model="calendar.filters"
write_field="partner_id" filter_field="partner_checked" avatar_field="avatar_128"/>
<field name="partner_id" string="Organizer" options="{'icon': 'fa fa-user-o'}"/>
<field name="user_id"/>
<field name="start"/>
<field name="stop"/>
<field name="allday"/>
<field name="res_model_name" invisible="not res_model_name"
options="{'icon': 'fa fa-link', 'shouldOpenRecord': true}"/>
</calendar>
`;
async function selectTimeStart(startDateTime) {
const [startDate, startTime] = startDateTime.split(" ");
const startCol = findDateColumn(startDate);
const startRow = findTimeRow(startTime);
await scrollTo(startRow);
const startColRect = startCol.getBoundingClientRect();
const startRowRect = startRow.getBoundingClientRect();
await contains(startRow).click({
position: {
x: startColRect.x + startColRect.width / 2,
y: startRowRect.y + 1,
},
});
}
beforeEach(async () => {
mockDate("2016-12-12 08:00:00", 0);
const { env: pyEnv } = await makeMockServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ name: "Partner 1" },
{ name: "Partner 2" },
]);
serverData.partnerId_1 = partnerId_1;
serverData.partnerId_2 = partnerId_2;
serverData.userId = pyEnv["res.users"].create({ name: "User 1", partner_id: partnerId_1 });
serverData.attendeeIds = pyEnv["calendar.attendee"].create([
{ partner_id: serverState.partnerId },
{ partner_id: partnerId_1 },
{ partner_id: partnerId_2 },
]);
pyEnv["calendar.filters"].create([
{ partner_id: partnerId_1, partner_checked: true, user_id: serverState.userId },
{ partner_id: partnerId_2, partner_checked: true, user_id: serverData.userId },
]);
pyEnv["calendar.event"].create([
{
name: "event 1",
start: "2016-12-11 00:00:00",
stop: "2016-12-11 01:00:00",
attendee_ids: serverData.attendeeIds,
partner_ids: [serverState.partnerId, partnerId_1, partnerId_2],
},
{
name: "event 2",
start: "2016-12-12 10:55:05",
stop: "2016-12-12 14:55:05",
attendee_ids: [serverData.attendeeIds[0], serverData.attendeeIds[1]],
partner_ids: [serverState.partnerId, partnerId_1],
},
]);
});
test("Linked record rendering", async () => {
const pyEnv = MockServer.current.env;
onRpc("res.users", "has_group", () => true);
onRpc("res.users", "check_synchronization_status", () => ({}));
onRpc("res.partner", "get_attendee_detail", () => []);
onRpc("/calendar/check_credentials", () => ({}));
const { id: modelId, display_name } = pyEnv["ir.model"].search_read(
[["model", "=", "res.partner"]],
["display_name"]
)[0];
const eventId = pyEnv["calendar.event"].create({
user_id: serverData.userId,
name: "event With record",
start: "2016-12-11 09:00:00",
stop: "2016-12-11 10:00:00",
attendee_ids: serverData.attendeeIds,
partner_ids: [serverState.partnerId, serverData.partnerId_1, serverData.partnerId_2],
res_model_id: modelId,
});
await mountView({ type: "calendar", resModel: "calendar.event", arch });
expect(".o_calendar_renderer .fc-view").toHaveCount(1);
await changeScale("week");
await clickEvent(eventId);
expect(".fa-link").toHaveCount(1, { message: "A link icon should be present" });
expect("li a[href='#']").toHaveText(display_name);
});
test("Default duration rendering", async () => {
onRpc("res.users", "has_group", () => true);
onRpc("res.users", "check_synchronization_status", () => ({}));
onRpc("res.partner", "get_attendee_detail", () => []);
onRpc("/calendar/check_credentials", () => ({}));
await mountView({ type: "calendar", resModel: "calendar.event", arch });
expandCalendarView();
await changeScale("week");
await selectTimeStart("2016-12-15 15:00:00");
await contains(".o-calendar-quick-create--input").edit("Event with new duration", {
confirm: false,
});
await contains(".o-calendar-quick-create--create-btn").click();
// This new event is the third
await clickEvent(3);
expect("div[name='start'] div").toHaveText("Dec 15, 3:00 PM");
expect("div[name='stop'] div").toHaveText("Dec 15, 6:15 PM", {
message: "The duration should be 3.25 hours",
});
});

View file

@ -0,0 +1,79 @@
import { defineCalendarModels } from "@calendar/../tests/calendar_test_helpers";
import { beforeEach, expect, test } from "@odoo/hoot";
import { contains, makeMockServer, mountView, onRpc } from "@web/../tests/web_test_helpers";
import { getOrigin } from "@web/core/utils/urls";
defineCalendarModels();
const serverData = {};
beforeEach(async () => {
const { env: pyEnv } = await makeMockServer();
serverData.partnerIds = pyEnv["res.partner"].create([{ name: "Zeus" }, { name: "Azdaha" }]);
serverData.eventId = pyEnv["calendar.event"].create({
name: "event 1",
partner_ids: serverData.partnerIds,
});
});
test("Many2ManyAttendee: basic rendering", async () => {
onRpc("get_attendee_detail", (request) => {
expect.step("get_attendee_detail");
expect(request.model).toBe("res.partner");
expect(request.args[0]).toEqual(serverData.partnerIds);
expect(request.args[1]).toEqual([serverData.eventId]);
return [
{ id: serverData.partnerIds[0], name: "Zeus", status: "accepted", color: 0 },
{ id: serverData.partnerIds[1], name: "Azdaha", status: "tentative", color: 0 },
];
});
await mountView({
type: "form",
resModel: "calendar.event",
resId: serverData.eventId,
arch: /*xml*/ `
<form>
<field name="partner_ids" widget="many2manyattendee"/>
</form>
`,
});
expect(".o_field_widget[name='partner_ids'] div.o_field_tags").toHaveCount(1);
expect(".o_field_widget[name='partner_ids'] .o_tag").toHaveCount(2);
expect(".o_field_widget[name='partner_ids'] .o_tag:eq(0)").toHaveText("Zeus");
expect(
".o_field_widget[name='partner_ids'] .o_tag:eq(0) .attendee_tag_status.o_attendee_status_accepted"
).toHaveCount(1);
expect(".o_field_widget[name='partner_ids'] .o_tag:eq(1)").toHaveText("Azdaha");
expect(
".o_field_widget[name='partner_ids'] .o_tag:eq(1) .attendee_tag_status.o_attendee_status_tentative"
).toHaveCount(1);
expect(".o_field_widget[name='partner_ids'] .o_tag:eq(0) img").toHaveCount(1);
expect(".o_field_widget[name='partner_ids'] .o_tag:eq(0) img").toHaveAttribute(
"data-src",
`${getOrigin()}/web/image/res.partner/${serverData.partnerIds[0]}/avatar_128`
);
expect.verifySteps(["get_attendee_detail"]);
});
test("Many2ManyAttendee: remove own attendee", async () => {
onRpc("get_attendee_detail", () => [
{ id: serverData.partnerIds[0], name: "Zeus", status: "accepted", color: 0 },
{ id: serverData.partnerIds[1], name: "Azdaha", status: "tentative", color: 0 },
]);
await mountView({
type: "form",
resModel: "calendar.event",
resId: serverData.eventId,
arch: /*xml*/ `
<form>
<field name="partner_ids" widget="many2manyattendee"/>
</form>
`,
});
expect(".o_field_widget[name='partner_ids'] .o_tag").toHaveCount(2);
// Attendee must be able to uninvite itself from the event.
await contains(".o_field_widget[name='partner_ids'] .o_delete", { visible: false }).click();
await contains(".o_form_button_save").click();
expect(".o_field_widget[name='partner_ids'] .o_tag").toHaveCount(1);
});

View file

@ -0,0 +1,78 @@
import { defineCalendarModels } from "@calendar/../tests/calendar_test_helpers";
import { click, contains, start, startServer } from "@mail/../tests/mail_test_helpers";
import { test } from "@odoo/hoot";
import {
asyncStep,
mockService,
onRpc,
preloadBundle,
serverState,
waitForSteps,
} from "@web/../tests/web_test_helpers";
defineCalendarModels();
preloadBundle("web.fullcalendar_lib");
test("can listen on bus and display notifications in DOM and click OK", async () => {
const pyEnv = await startServer();
onRpc("/calendar/notify_ack", () => asyncStep("notify_ack"));
await start();
pyEnv["bus.bus"]._sendone(serverState.partnerId, "calendar.alarm", [
{
alarm_id: 1,
event_id: 2,
title: "Meeting",
message: "Very old meeting message",
timer: 0,
notify_at: "1978-04-14 12:45:00",
},
]);
await contains(".o_notification", { text: "Meeting. Very old meeting message" });
await click(".o_notification_buttons button", { text: "OK" });
await contains(".o_notification", { count: 0 });
await waitForSteps(["notify_ack"]);
});
test("can listen on bus and display notifications in DOM and click Detail", async () => {
mockService("action", {
doAction(actionId) {
asyncStep(actionId.type);
},
});
const pyEnv = await startServer();
await start();
pyEnv["bus.bus"]._sendone(serverState.partnerId, "calendar.alarm", [
{
alarm_id: 1,
event_id: 2,
title: "Meeting",
message: "Very old meeting message",
timer: 0,
notify_at: "1978-04-14 12:45:00",
},
]);
await contains(".o_notification", { text: "Meeting. Very old meeting message" });
await click(".o_notification_buttons button", { text: "Details" });
await contains(".o_notification", { count: 0 });
await waitForSteps(["ir.actions.act_window"]);
});
test("can listen on bus and display notifications in DOM and click Snooze", async () => {
const pyEnv = await startServer();
onRpc("/calendar/notify_ack", () => asyncStep("notify_ack"));
await start();
pyEnv["bus.bus"]._sendone(serverState.partnerId, "calendar.alarm", [
{
alarm_id: 1,
event_id: 2,
title: "Meeting",
message: "Very old meeting message",
timer: 0,
notify_at: "1978-04-14 12:45:00",
},
]);
await contains(".o_notification", { text: "Meeting. Very old meeting message" });
await click(".o_notification button", { text: "Snooze" });
await contains(".o_notification", { count: 0 });
await waitForSteps([]);
});

View file

@ -1,120 +0,0 @@
/** @odoo-module */
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { calendarNotificationService } from "@calendar/js/services/calendar_notification_service";
import { click, contains } from "@web/../tests/utils";
import { registry } from "@web/core/registry";
import { start } from "@mail/../tests/helpers/test_utils";
const serviceRegistry = registry.category("services");
QUnit.module("Calendar Notification", (hooks) => {
hooks.beforeEach(() => {
serviceRegistry.add("calendarNotification", calendarNotificationService);
});
QUnit.test(
"can listen on bus and display notifications in DOM and click OK",
async (assert) => {
const pyEnv = await startServer();
const mockRPC = (route, args) => {
if (route === "/calendar/notify") {
return Promise.resolve([]);
}
if (route === "/calendar/notify_ack") {
assert.step("notifyAck");
return Promise.resolve(true);
}
};
await start({ mockRPC });
pyEnv["bus.bus"]._sendone(pyEnv.currentPartner, "calendar.alarm", [
{
alarm_id: 1,
event_id: 2,
title: "Meeting",
message: "Very old meeting message",
timer: 0,
notify_at: "1978-04-14 12:45:00",
},
]);
await contains(".o_notification", { text: "Very old meeting message" });
await click(".o_notification_buttons button", { text: "OK" });
await contains(".o_notification", { count: 0 });
assert.verifySteps(["notifyAck"]);
}
);
QUnit.test(
"can listen on bus and display notifications in DOM and click Detail",
async (assert) => {
const pyEnv = await startServer();
const mockRPC = (route, args) => {
if (route === "/calendar/notify") {
return Promise.resolve([]);
}
};
const fakeActionService = {
name: "action",
start() {
return {
doAction(actionId) {
assert.step(actionId.type);
return Promise.resolve(true);
},
loadState(state, options) {
return Promise.resolve(true);
},
};
},
};
serviceRegistry.add("action", fakeActionService, { force: true });
await start({ mockRPC });
pyEnv["bus.bus"]._sendone(pyEnv.currentPartner, "calendar.alarm", [
{
alarm_id: 1,
event_id: 2,
title: "Meeting",
message: "Very old meeting message",
timer: 0,
notify_at: "1978-04-14 12:45:00",
},
]);
await contains(".o_notification", { text: "Very old meeting message" });
await click(".o_notification_buttons button", { text: "Details" });
await contains(".o_notification", { count: 0 });
assert.verifySteps(["ir.actions.act_window"]);
}
);
QUnit.test(
"can listen on bus and display notifications in DOM and click Snooze",
async (assert) => {
const pyEnv = await startServer();
const mockRPC = (route, args) => {
if (route === "/calendar/notify") {
return Promise.resolve([]);
}
if (route === "/calendar/notify_ack") {
assert.step("notifyAck");
return Promise.resolve(true);
}
};
await start({ mockRPC });
pyEnv["bus.bus"]._sendone(pyEnv.currentPartner, "calendar.alarm", [
{
alarm_id: 1,
event_id: 2,
title: "Meeting",
message: "Very old meeting message",
timer: 0,
notify_at: "1978-04-14 12:45:00",
},
]);
await contains(".o_notification", { text: "Very old meeting message" });
await click(".o_notification button", { text: "Snooze" });
await contains(".o_notification", { count: 0 });
assert.verifySteps([], "should only close the notification withtout calling a rpc");
}
);
});

View file

@ -0,0 +1,20 @@
import { CalendarEvent } from "./mock_server/mock_models/calendar_event";
import { CalendarAttendee } from "./mock_server/mock_models/calendar_attendee";
import { ResUsers } from "./mock_server/mock_models/res_users";
import { MailActivity } from "./mock_server/mock_models/mail_activity";
import { CalendarFilters } from "./mock_server/mock_models/calendar_filters";
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { defineModels } from "@web/../tests/web_test_helpers";
export const calendarModels = {
CalendarAttendee,
CalendarEvent,
CalendarFilters,
ResUsers,
MailActivity,
};
export function defineCalendarModels() {
return defineModels({ ...mailModels, ...calendarModels });
}

View file

@ -1,129 +0,0 @@
/** @odoo-module **/
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module(
"calendar",
{
beforeEach: function () {
target = getFixture();
serverData = {
models: {
event: {
fields: {
partner_ids: {
string: "Partners",
type: "many2many",
relation: "partner",
},
},
records: [
{
id: 14,
partner_ids: [1, 2],
},
],
},
partner: {
fields: {
name: { string: "Name", type: "char" },
},
records: [
{
id: 1,
name: "Jesus",
},
{
id: 2,
name: "Mahomet",
},
],
},
},
};
setupViewRegistries();
},
},
function () {
QUnit.test("Many2ManyAttendee: basic rendering", async function (assert) {
await makeView({
type: "form",
resModel: "event",
serverData,
resId: 14,
arch: `
<form>
<field name="partner_ids" widget="many2manyattendee"/>
</form>`,
mockRPC(route, args) {
if (args.method === "get_attendee_detail") {
assert.step(args.method);
assert.strictEqual(
args.model,
"res.partner",
"the method should only be called on res.partner"
);
assert.deepEqual(
args.args[0],
[1, 2],
"the partner ids should be passed as argument"
);
assert.deepEqual(
args.args[1],
[14],
"the event id should be passed as argument"
);
return Promise.resolve([
{ id: 1, name: "Jesus", status: "accepted", color: 0 },
{ id: 2, name: "Mahomet", status: "tentative", color: 0 },
]);
}
},
});
assert.hasClass(
target.querySelector('.o_field_widget[name="partner_ids"] div'),
"o_field_tags"
);
assert.containsN(
target,
'.o_field_widget[name="partner_ids"] .badge',
2,
"there should be 2 tags"
);
const badges = target.querySelectorAll('.o_field_widget[name="partner_ids"] .badge');
assert.strictEqual(
badges[0].textContent.trim(),
"Jesus",
"the tag should be correctly named"
);
assert.hasClass(
badges[0].querySelector("img"),
"o_attendee_border_accepted",
"Jesus should attend the meeting"
);
assert.strictEqual(
badges[1].textContent.trim(),
"Mahomet",
"the tag should be correctly named"
);
assert.hasClass(
badges[1].querySelector("img"),
"o_attendee_border_tentative",
"Mohamet should still confirm his attendance to the meeting"
);
assert.containsOnce(badges[0], "img", "should have img tag");
assert.hasAttrValue(
badges[0].querySelector("img"),
"data-src",
"/web/image/partner/1/avatar_128",
"should have correct avatar image"
);
assert.verifySteps(["get_attendee_detail"]);
});
}
);

View file

@ -1,93 +0,0 @@
/** @odoo-module **/
// ensure mail override is applied first.
import '@mail/../tests/helpers/mock_server';
import { patch } from '@web/core/utils/patch';
import { MockServer } from '@web/../tests/helpers/mock_server';
import { datetime_to_str } from 'web.time';
patch(MockServer.prototype, 'calendar', {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
async _performRPC(route, args) {
// mail.activity methods
if (args.model === 'mail.activity' && args.method === 'action_create_calendar_event') {
return {
type: 'ir.actions.act_window',
name: "Meetings",
res_model: 'calendar.event',
view_mode: 'calendar',
views: [[false, 'calendar']],
target: 'current',
};
}
// calendar.event methods
if (args.model === 'calendar.event' && args.method === 'check_access_rights') {
return true;
}
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Private Mocked Methods
//--------------------------------------------------------------------------
/**
* Simulates `_systray_get_calendar_event_domain` on `res.users`.
*
* @private
*/
_mockResUsers_SystrayGetCalendarEventDomain() {
const startDate = new Date();
startDate.setUTCHours(0, 0, 0, 0);
const endDate = new Date();
endDate.setUTCHours(23, 59, 59, 999);
const currentPartnerAttendeeIds = this.pyEnv['calendar.attendee'].search([['partner_id', '=', this.currentPartnerId]]);
return [
'&',
'|',
'&',
'|',
['start', '>=', datetime_to_str(startDate)],
['stop', '>=', datetime_to_str(startDate)],
['start', '<=', datetime_to_str(endDate)],
'&',
['allday', '=', true],
['start_date', '=', datetime_to_str(startDate)],
['attendee_ids', 'in', currentPartnerAttendeeIds],
];
},
/**
* Simulates `systray_get_activities` on `res.users`.
*
* @override
*/
_mockResUsersSystrayGetActivities() {
const activities = this._super(...arguments);
const meetingsLines = this.pyEnv['calendar.event'].searchRead(
this._mockResUsers_SystrayGetCalendarEventDomain(),
{
fields: ['id', 'start', 'name', 'allday', 'attendee_status'],
order: 'start',
}
).filter(meetingLine => meetingLine['attendee_status'] !== 'declined');
if (meetingsLines.length) {
activities.unshift({
id: 'calendar.event', // for simplicity
meetings: meetingsLines,
model: 'calendar.event',
name: 'Today\'s Meetings',
type: 'meeting',
});
}
return activities;
},
});

View file

@ -0,0 +1,15 @@
import { patch } from "@web/core/utils/patch";
import { MockServer } from '@web/../tests/helpers/mock_server';
patch(MockServer.prototype, {
/**
* @override
*/
async _performRPC(route, args) {
// calendar.event methods
if (args.model === 'calendar.event' && args.method === 'has_access') {
return true;
}
return super._performRPC(...arguments);
},
});

View file

@ -1,5 +0,0 @@
/** @odoo-module **/
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch(['calendar.event', 'calendar.attendee']);

View file

@ -1,8 +0,0 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
const viewArchsRegistry = registry.category('bus.view.archs');
const calendarArchsRegistry = viewArchsRegistry.category('calendar');
calendarArchsRegistry.add('default', '<calendar date_start="start"/>');

View file

@ -1,129 +0,0 @@
/** @odoo-module **/
import FormView from "web.FormView";
import testUtils from "web.test_utils";
var createView = testUtils.createView;
QUnit.module(
"Legacy calendar",
{
beforeEach: function () {
this.data = {
event: {
fields: {
partner_ids: { string: "Partners", type: "many2many", relation: "partner" },
},
records: [
{
id: 14,
partner_ids: [1, 2],
},
],
},
partner: {
fields: {
name: { string: "Name", type: "char" },
},
records: [
{
id: 1,
name: "Jesus",
},
{
id: 2,
name: "Mahomet",
},
],
},
};
},
},
function () {
QUnit.test("many2manyattendee widget: basic rendering", async function (assert) {
assert.expect(12);
var form = await createView({
View: FormView,
model: "event",
data: this.data,
res_id: 14,
debug: 1,
arch:
"<form>" + '<field name="partner_ids" widget="many2manyattendee"/>' + "</form>",
mockRPC: function (route, args) {
if (args.method === "get_attendee_detail") {
assert.strictEqual(
args.model,
"res.partner",
"the method should only be called on res.partner"
);
assert.deepEqual(
args.args[0],
[1, 2],
"the partner ids should be passed as argument"
);
assert.deepEqual(
args.args[1],
[14],
"the event id should be passed as argument"
);
return Promise.resolve([
{ id: 1, name: "Jesus", status: "accepted", color: 0 },
{ id: 2, name: "Mahomet", status: "tentative", color: 0 },
]);
}
return this._super.apply(this, arguments);
},
});
assert.hasClass(form.$('.o_field_widget[name="partner_ids"]'), "o_field_many2manytags");
assert.containsN(
form,
'.o_field_widget[name="partner_ids"] .badge',
2,
"there should be 2 tags"
);
assert.strictEqual(
form.$('.o_field_widget[name="partner_ids"] .badge:first').text().trim(),
"Jesus",
"the tag should be correctly named"
);
assert.hasClass(
form.$('.o_field_widget[name="partner_ids"] .badge:first img'),
"o_attendee_border_accepted",
"Jesus should attend the meeting"
);
assert.strictEqual(
form.$('.o_field_widget[name="partner_ids"] .badge[data-id="2"]').text().trim(),
"Mahomet",
"the tag should be correctly named"
);
assert.hasClass(
form.el.querySelector(
'.o_field_widget[name="partner_ids"] .badge[data-id="2"] img'
),
"o_attendee_border_tentative",
"Mohamet should still confirm his attendance to the meeting"
);
assert.hasClass(
form.el.querySelector(".o_field_many2manytags"),
"avatar",
"should have avatar class"
);
assert.containsOnce(
form,
".o_field_many2manytags.avatar.o_field_widget .badge:first img",
"should have img tag"
);
assert.hasAttrValue(
form.$(".o_field_many2manytags.avatar.o_field_widget .badge:first img"),
"data-src",
"/web/image/partner/1/avatar_128",
"should have correct avatar image"
);
form.destroy();
});
}
);

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class CalendarAttendee extends models.ServerModel {
_name = "calendar.attendee";
}

View file

@ -0,0 +1,17 @@
import { models, fields, serverState } from "@web/../tests/web_test_helpers";
export class CalendarEvent extends models.ServerModel {
_name = "calendar.event";
user_id = fields.Generic({ default: serverState.userId });
partner_id = fields.Generic({ default: serverState.partnerId });
partner_ids = fields.Generic({ default: [[6, 0, [serverState.partnerId]]] });
has_access() {
return true;
}
get_default_duration() {
return 3.25;
}
}

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class CalendarFilters extends models.ServerModel {
_name = "calendar.filters";
}

View file

@ -0,0 +1,39 @@
import { mailModels, openView } from "@mail/../tests/mail_test_helpers";
import { fields } from "@web/../tests/web_test_helpers";
export class MailActivity extends mailModels.MailActivity {
name = fields.Char();
async action_create_calendar_event() {
await openView({
res_model: "calendar.event",
views: [[false, "calendar"]],
});
return {
type: "ir.actions.act_window",
name: "Meetings",
res_model: "calendar.event",
view_mode: "calendar",
views: [[false, "calendar"]],
target: "current",
};
}
unlink_w_meeting() {
const eventIds = this.map((act) => act.calendar_event_id);
const res = this.unlink(arguments[0]);
this.env["calendar.event"].unlink(eventIds);
return res;
}
/** @param {number[]} ids */
_to_store(store) {
super._to_store(...arguments);
for (const activity of this) {
if (activity.calendar_event_id) {
store._add_record_fields(this.browse(activity.id), {
calendar_event_id: activity.calendar_event_id,
});
}
}
}
}

View file

@ -0,0 +1,67 @@
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { serverState } from "@web/../tests/web_test_helpers";
import { serializeDateTime } from "@web/core/l10n/dates";
const { DateTime } = luxon;
export class ResUsers extends mailModels.ResUsers {
/**
* Simulates `_systray_get_calendar_event_domain` on `res.users`.
*
* @private
*/
_systray_get_calendar_event_domain() {
const startDate = DateTime.fromObject({
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
const endDate = DateTime.fromObject({
hours: 23,
minutes: 59,
seconds: 59,
milliseconds: 999,
});
const currentPartnerAttendeeIds = this.env["calendar.attendee"].search([
["partner_id", "=", serverState.partnerId],
["state", "!=", "declined"],
]);
return [
"&",
"|",
"&",
"|",
["start", ">=", serializeDateTime(startDate)],
["stop", ">=", serializeDateTime(startDate)],
["start", "<=", serializeDateTime(endDate)],
// FIXME: Makes "activity_menu.test.js" fail
// "&",
// ["allday", "=", true],
// ["start_date", "=", serializeDateTime(startDate)],
["attendee_ids", "in", [...currentPartnerAttendeeIds]],
];
}
/** @override */
_get_activity_groups() {
const activities = super._get_activity_groups();
const meetingsLines = this.env["calendar.event"].search_read(
this._systray_get_calendar_event_domain(),
{
fields: ["id", "start", "name", "allday"],
order: "start",
}
);
if (meetingsLines.length) {
activities.unshift({
id: "calendar.event", // for simplicity
meetings: meetingsLines,
model: "calendar.event",
name: "Today's Meetings",
type: "meeting",
});
}
return activities;
}
}

View file

@ -1,48 +0,0 @@
/** @odoo-module **/
import { start, startServer } from '@mail/../tests/helpers/test_utils';
import { patchDate, patchWithCleanup } from "@web/../tests/helpers/utils";
QUnit.module('calendar', {}, function () {
QUnit.module('ActivityMenu');
QUnit.test('activity menu widget:today meetings', async function (assert) {
assert.expect(6);
patchDate(2018, 3, 20, 6, 0, 0);
const pyEnv = await startServer();
const calendarAttendeeId1 = pyEnv['calendar.attendee'].create({ partner_id: pyEnv.currentPartnerId });
pyEnv['calendar.event'].create([
{
res_model: "calendar.event",
name: "meeting1",
start: "2018-04-20 06:30:00",
attendee_ids: [calendarAttendeeId1],
},
{
res_model: "calendar.event",
name: "meeting2",
start: "2018-04-20 09:30:00",
attendee_ids: [calendarAttendeeId1],
},
]);
const { click, env } = await start();
assert.containsOnce(document.body, '.o_ActivityMenuView', 'should contain an instance of widget');
await click('.dropdown-toggle[title="Activities"]');
patchWithCleanup(env.services.action, {
doAction(action) {
assert.strictEqual(action, "calendar.action_calendar_event", 'should open meeting calendar view in day mode');
},
});
assert.ok(document.querySelector('.o_meeting_filter'), "should be a meeting");
assert.containsN(document.body, '.o_meeting_filter', 2, 'there should be 2 meetings');
assert.hasClass(document.querySelector('.o_meeting_filter'), 'o_meeting_bold', 'this meeting is yet to start');
assert.doesNotHaveClass(document.querySelectorAll('.o_meeting_filter')[1], 'o_meeting_bold', 'this meeting has been started');
await click('.o_ActivityMenuView_activityGroup');
});
});

View file

@ -1,155 +1,132 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
const todayDate = function() {
let now = new Date();
let year = now.getFullYear();
let month = String(now.getMonth() + 1).padStart(2, '0');
let day = String(now.getDate()).padStart(2, '0');
const todayDate = function () {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${month}/${day}/${year} 10:00:00`;
};
tour.register('calendar_appointments_hour_tour', {
url: '/web',
test: true,
}, [
tour.stepUtils.showAppsMenuItem(),
{
trigger: '.o_app[data-menu-xmlid="calendar.mail_menu_calendar"]',
content: 'Open Calendar',
run: 'click',
},
{
trigger: '.o-calendar-button-new',
content: 'Create a new event',
run: 'click',
},
{
trigger: '#name',
content: 'Give a name to the new event',
run: 'text TEST EVENT',
},
{
trigger: '#start',
content: 'Give a date to the new event',
run: `text ${todayDate()}`,
},
{
trigger: '.fa-cloud-upload',
content: 'Save the new event',
run: 'click',
},
{
trigger: '.dropdown-item:contains("Calendar")',
content: 'Go back to Calendar view',
run: 'click',
},
{
trigger: '.dropdown-toggle:contains("Week")',
content: 'Click to change calendar view',
run: 'click',
},
{
trigger: '.dropdown-item:contains("Month")',
content: 'Change the calendar view to Month',
run: 'click',
},
{
trigger: '.fc-day-header:contains("Monday")',
content: 'Change the calendar view to week',
},
{
trigger: '.fc-time:contains("10:00")',
content: 'Check the time is properly displayed',
},
{
trigger: '.o_event_title:contains("TEST EVENT")',
content: 'Check the event title',
},
]);
registry.category("web_tour.tours").add("calendar_appointments_hour_tour", {
url: "/odoo",
steps: () => [
stepUtils.showAppsMenuItem(),
{
trigger: '.o_app[data-menu-xmlid="calendar.mail_menu_calendar"]',
content: "Open Calendar",
run: "click",
},
{
trigger: ".o-calendar-button-new",
content: "Create a new event",
run: "click",
},
{
trigger: "#name_0",
content: "Give a name to the new event",
run: "edit TEST EVENT",
},
{
trigger: "div[name='start'] button",
content: "Open the date picker",
run: "click",
},
{
trigger: "#start_0",
content: "Give a date to the new event",
run: `edit ${todayDate()}`,
},
{
trigger: "#duration_0",
content: "Give a duration to the new event",
run: "edit 02:00",
},
{
trigger: ".fa-cloud-upload",
content: "Save the new event",
run: "click",
},
{
trigger: ".o_back_button",
content: "Go back to Calendar view",
run: "click",
},
{
trigger: ".scale_button_selection",
content: "Click to change calendar view",
run: "click",
},
{
trigger: '.dropdown-item:contains("Month")',
content: "Change the calendar view to Month",
run: "click",
},
{
trigger: ".fc-col-header-cell.fc-day.fc-day-mon",
content: "Check the day is properly displayed",
run: "click",
},
{
trigger: '.fc-time:contains("10:00")',
content: "Check the time is properly displayed",
run: "click",
},
{
trigger: '.o_event_title:contains("TEST EVENT")',
content: "Check the event title",
},
],
});
tour.register('test_calendar_delete_tour', {
test: true,
},
[
{
content: 'Select filter (everybody)',
trigger: 'div[data-value="all"] input',
},
{
content: 'Click on the event (focus + waiting)',
trigger: 'a .fc-content:contains("Test Event")',
async run() {
$('a .fc-content:contains("Test Event")').click();
await new Promise((r) => setTimeout(r, 1000));
$('a .fc-content:contains("Test Event")').click();
const clickOnTheEvent = {
content: "Click on the event (focus + waiting)",
trigger: 'a .fc-event-main:contains("Test Event")',
async run(actions) {
await actions.click();
await new Promise((r) => setTimeout(r, 1000));
const custom = document.querySelector(".o_cw_custom_highlight");
if (custom) {
custom.click();
}
},
{
content: 'Delete the event',
trigger: '.o_cw_popover_delete',
},
{
content: 'Validate the deletion',
trigger:'button:contains("Ok")',
async run() {
$('button:contains("Ok")').click();
await new Promise((r) => setTimeout(r, 1000));
}
},
]);
};
tour.register('test_calendar_decline_tour', {
test: true,
},
[
{
content: 'Click on the event (focus + waiting)',
trigger: 'a .fc-content:contains("Test Event")',
async run() {
$('a .fc-content:contains("Test Event")').click();
await new Promise((r) => setTimeout(r, 1000));
$('a .fc-content:contains("Test Event")').click();
}
},
{
content: 'Delete the event',
trigger: '.o_cw_popover_delete',
},
{
content: 'Wait declined status',
trigger: '.o_attendee_status_declined',
},
]);
registry.category("web_tour.tours").add("test_calendar_delete_tour", {
steps: () => [
clickOnTheEvent,
{
trigger: ".o_cw_popover",
},
{
content: "Delete the event",
trigger: ".o_cw_popover_delete",
run: "click",
},
{
content: "Validate the deletion",
trigger: 'button:contains("Delete")',
run: "click",
},
],
});
tour.register('test_calendar_decline_with_everybody_filter_tour', {
test: true,
},
[
{
content: 'Select filter (everybody)',
trigger: 'div[data-value="all"] input',
},
{
content: 'Click on the event (focus + waiting)',
trigger: 'a .fc-content:contains("Test Event")',
async run() {
$('a .fc-content:contains("Test Event")').click();
await new Promise((r) => setTimeout(r, 1000));
$('a .fc-content:contains("Test Event")').click();
}
},
{
content: 'Delete the event',
trigger: '.o_cw_popover_delete',
},
{
content: 'Select filter (everybody)',
trigger: 'div[data-value="all"] input',
},
{
content: 'Wait declined status',
trigger: '.o_attendee_status_declined',
},
]);
registry.category("web_tour.tours").add("test_calendar_decline_tour", {
steps: () => [
clickOnTheEvent,
{
trigger: ".o_cw_popover",
},
{
content: "Delete the event",
trigger: ".o_cw_popover_delete",
run: "click",
},
{
content: "Wait declined status",
trigger: ".o_attendee_status_declined",
},
],
});