[IMP] web,calendar, * : revamp of the calendar view

In this PR we've revamped the calendar module as well as the calendar view for it to look cleaner. We also took the opportunity to add onboarding steps for the calendar app to make the configuration for integration with third-party calendar services more intuitive. On top of that, some general changes to the calendar views were made, allowing scales and sidebar state to be saved in the session storage, so that the user always lands in a familiar mode of the calendar. For the calendar module, a lot of changes were done in the UI of the calendar event form view to make it more intuitive.

Enterprise PR: https://github.com/odoo/enterprise/pull/48941

task-3455051
task-3389317

closes odoo/odoo#138670

Related: odoo/enterprise#48941
Signed-off-by: Arnaud Joset (arj) <arj@odoo.com>
Co-authored-by: Leonardo (lpr) <lpr@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-10-23 20:07:23 +00:00
committed by Odoo Online
co-authored by Leonardo
80 changed files with 1645 additions and 1322 deletions
+2 -1
View File
@@ -5,7 +5,7 @@
'name': 'Calendar',
'version': '1.1',
'sequence': 165,
'depends': ['base', 'mail'],
'depends': ['base', 'mail', 'onboarding'],
'summary': "Schedule employees' meetings",
'description': """
This is a full-featured calendar system.
@@ -28,6 +28,7 @@ If you need to manage your meetings, you should install the CRM module.
'data/calendar_cron.xml',
'data/mail_template_data.xml',
'data/calendar_data.xml',
'data/calendar_onboarding_data.xml',
'data/mail_activity_type_data.xml',
'data/mail_message_subtype_data.xml',
'views/mail_activity_views.xml',
+5
View File
@@ -113,3 +113,8 @@ class CalendarController(http.Controller):
event._create_videocall_channel()
return request.redirect(event.videocall_channel_id.invitation_url)
@http.route('/calendar/check_credentials', type='json', auth='user')
def check_calendar_credentials(self):
# method should be overwritten by sync providers
return {}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ONBOARDING STEPS -->
<record id="onboarding_onboarding_step_setup_calendar_integration" model="onboarding.onboarding.step">
<field name="title">Connect your calendar</field>
<field name="description">With Outlook or Google</field>
<field name="button_text">Add</field>
<field name="done_text">Done!</field>
<field name="panel_step_open_action_name">action_view_start_calendar_sync</field>
<field name="step_image" type="base64" file="base/static/img/onboarding_calendar.png"></field>
<field name="step_image_filename">onboarding_calendar.png</field>
<field name="step_image_alt">Onboarding Calendar Synchronization</field>
<field name="sequence">1</field>
</record>
<record id="onboarding_onboarding_calendar" model="onboarding.onboarding">
<field name="name">Calendar Onboarding</field>
<field name="step_ids" eval="[(4, ref('calendar.onboarding_onboarding_step_setup_calendar_integration'))]"/>
<field name="panel_close_action_name">action_close_calendar_onboarding</field>
<field name="route_name">calendar</field>
</record>
</data>
</odoo>
+2
View File
@@ -14,3 +14,5 @@ from . import mail_activity
from . import mail_activity_mixin
from . import mail_activity_type
from . import res_users
from . import onboarding_onboarding
from . import onboarding_onboarding_step
+4 -4
View File
@@ -24,9 +24,9 @@ class Attendee(models.Model):
STATE_SELECTION = [
('needsAction', 'Needs Action'),
('tentative', 'Uncertain'),
('declined', 'Declined'),
('accepted', 'Accepted'),
('tentative', 'Maybe'),
('declined', 'No'),
('accepted', 'Yes'),
]
# event
@@ -40,7 +40,7 @@ class Attendee(models.Model):
access_token = fields.Char('Invitation Token', default=_default_access_token)
mail_tz = fields.Selection(_tz_get, compute='_compute_mail_tz', help='Timezone used for displaying time in the mail template')
# state
state = fields.Selection(STATE_SELECTION, string='Status', readonly=True, default='needsAction')
state = fields.Selection(STATE_SELECTION, string='Status', default='needsAction')
availability = fields.Selection(
[('free', 'Available'), ('busy', 'Busy')], 'Available/Busy', readonly=True)
+17
View File
@@ -183,6 +183,9 @@ class Meeting(models.Model):
# attendees
attendee_ids = fields.One2many(
'calendar.attendee', 'event_id', 'Participant')
current_attendee = fields.Many2one("calendar.attendee", compute="_compute_current_attendee", search="_search_current_attendee")
current_status = fields.Selection(string="Attending?", related="current_attendee.state", readonly=False)
should_show_status = fields.Boolean(compute="_compute_should_show_status")
partner_ids = fields.Many2many(
'res.partner', 'calendar_event_res_partner_rel',
string='Attendees', default=_default_partners)
@@ -248,6 +251,20 @@ class Meeting(models.Model):
awaiting_count = fields.Integer(compute="_compute_attendees_count")
user_can_edit = fields.Boolean(compute='_compute_user_can_edit')
@api.depends("attendee_ids")
def _compute_should_show_status(self):
for event in self:
event.should_show_status = event.current_attendee and any(attendee.partner_id != self.env.user.partner_id for attendee in event.attendee_ids)
@api.depends('attendee_ids', 'attendee_ids.state')
def _compute_current_attendee(self):
for event in self:
current_attendee = event.attendee_ids.filtered(lambda attendee: attendee.partner_id == self.env.user.partner_id)
event.current_attendee = current_attendee and current_attendee[0]
def _search_current_attendee(self, operator, value):
return [("id", operator, value)]
@api.depends('attendee_ids', 'attendee_ids.state', 'partner_ids')
def _compute_attendees_count(self):
for event in self:
@@ -0,0 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class Onboarding(models.Model):
_inherit = 'onboarding.onboarding'
@api.model
def action_close_calendar_onboarding(self):
self.action_close_panel('calendar.onboarding_onboarding_calendar')
@@ -0,0 +1,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class OnboardingStep(models.Model):
_inherit = 'onboarding.onboarding.step'
@api.model
def action_view_start_calendar_sync(self):
self.env['onboarding.onboarding.step'].action_validate_step('calendar.onboarding_onboarding_step_setup_calendar_integration')
action = self.env["ir.actions.actions"]._for_xml_id("calendar.action_view_start_calendar_sync")
return action
@@ -0,0 +1,44 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
const cogMenuRegistry = registry.category("cogMenu");
export class CalendarSyncCogMenu extends Component {
static props = {};
static template = "calendar.CalendarSyncCogMenu";
static components = { DropdownItem };
setup() {
this.actionService = useService("action");
}
startCalendarSync() {
this.actionService.doAction({
name: _t('Connect your Calendar'),
type: 'ir.actions.act_window',
res_model: 'calendar.provider.config',
views: [[false, "form"]],
view_mode: "form",
target: 'new',
context: {
'dialog_size': 'medium',
}
});
}
};
cogMenuRegistry.add(
'calendar-sync-cog-menu',
{
Component: CalendarSyncCogMenu,
groupNumber: 10,
isDisplayed: ({ config }) => {
return config.viewSubType === "attendee_calendar";
},
},
);
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.CalendarSyncCogMenu">
<DropdownItem class="'o_calendar_sync_menu'" onSelected.bind="startCalendarSync">
<i class="fa fa-fw fa-calendar me-1"/>Add integrations
</DropdownItem>
</t>
</templates>
+73 -91
View File
@@ -1,13 +1,11 @@
.o_cw_popover .o_cw_popover_fields_secondary {
max-height: 75vh;
& > li > div {
min-width: 0;
}
}
.o_cw_body {
max-width: 328px;
}
.o_cw_body .o_field_copy {
max-width: calc(100% - 6rem);
@@ -18,110 +16,94 @@
padding-top: 0px !important;
}
.o_field_many2manyattendee .o_field_tags {
.o_field_many2manyattendeeexpandable .o_field_tags {
flex-direction: column;
span.badge {
width: fit-content;
}
.o_field_many2many_selection {
flex: 1 0 auto;
}
}
.o_attendee_border {
border-width: 2px;
border-style: solid;
}
@for $-i from 1 through length($o-colors-complete) {
$-color: nth($o-colors-complete, $-i);
$-color-subtle: mix($o-white, $-color, 85%);
.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');
}
.o_calendar_renderer .fc-event.o_calendar_color_#{$-i - 1} {
&:not(.o_event_dot) {
--o-overlay-opacity: .5;
.o_cw_popover_link {
&.o_attendee_status_needsAction {
background-color: rgba($o-view-background-color, 0.9) !important;
}
&.o_attendee_status_declined {
text-decoration: line-through;
background-color: rgba($o-view-background-color, 0.9) !important;
}
}
.o_calendar_renderer {
.fc-event {
&.o_attendee_status_needsAction,
&.o_attendee_status_tentative,
&.o_attendee_status_declined,
&.o_attendee_status_alone {
border-width: 2px !important;
}
&.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 {
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;
}
}
}
}
@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.o_calendar_color_#{$i - 1} {
&.o_attendee_status_needsAction,
&.o_attendee_status_tentative,
&.o_attendee_status_declined,
&.o_attendee_status_alone {
&.o_cw_custom_highlight {
background-color: $color;
}
&.o_attendee_status_active {
--o-bg-opacity: 1;
}
&.o_attendee_status_tentative {
color: color-contrast($color) !important;
&.o_attendee_status_tentative, &.o_event_hatched {
--o-bg-opacity: .5;
.fc-bg {
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba(white, 0.4) 10px,
rgba(white, 0.4) 20px
rgba($-color, .3),
rgba($-color, .3) 10px,
rgba($-color, .6) 10px,
rgba($-color, .6) 20px
) !important;
}
}
&.o_attendee_status_alone,
&.o_attendee_status_needsAction {
--o-bg-opacity: .5;
}
&.o_attendee_status_declined {
--o-bg-opacity: 0;
}
}
&.o_event_dot {
--o-overlay-opacity: .25;
display: flex;
align-items: center;
gap: map-get($spacers, 1 );
border: none;
background-color: rgba($-color-subtle, var(--o-bg-opacity));
&:before {
content:'\f111'; // fa-circle
display: inline-block;
z-index: 2;
font-family: Fontawesome;
color: $-color;
}
&.o_attendee_status_needsAction, &.o_event_hatched {
&:before {
content: "\f1db"; // fa-circle-thin
}
}
&.o_attendee_status_tentative:before {
content: "\f059"; // fa-question-circle
}
&.o_attendee_status_declined:before {
content: "\f05e"; // fa-ban
}
&.o_attendee_status_alone {
content: "\f06a"; // fa-exclamation-circle
}
&:hover {
--o-bg-opacity: 1;
}
}
&.o_attendee_status_declined, &.o_event_striked {
.fc-content {
text-decoration: line-through;
}
}
}
}
@@ -1,25 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.AttendeeCalendarController" t-inherit="web.CalendarController" t-inherit-mode="primary">
<DatePicker position="after">
<div id="calendar_sync_wrapper" t-if="isSystemUser">
<div id="calendar_sync_title" class="o_calendar_sync text-center">
<span>Synchronize with:</span>
</div>
<div id="calendar_sync" class="container btn-group justify-content-center align-items-center gap-2">
<div id="google_calendar_sync" class="o_calendar_sync" t-if="isSystemUser">
<button type="button" id="google_sync_activate" class="btn btn-light" t-on-click="() => this.configureCalendarProviderSync('google')">
<b><i class='fa fa-plug fa-fw'/> Google</b>
</button>
</div>
<div id="microsoft_calendar_sync" class="o_calendar_sync" t-if="isSystemUser">
<button type="button" id="microsoft_sync_activate" class="btn btn-light" t-on-click="() => this.configureCalendarProviderSync('microsoft')">
<b><i class='fa fa-plug fa-fw'/> Outlook</b>
</button>
</div>
</div>
</div>
</DatePicker>
<xpath expr="//Layout" position="inside">
<t t-set-slot="control-panel-create-button">
<button class="btn btn-primary o-calendar-button-new me-1" t-on-click="onClickAddButton">New</button>
@@ -4,11 +4,16 @@ import { _t } from "@web/core/l10n/translation";
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 { onWillStart } from "@odoo/owl";
export class AttendeeCalendarModel extends CalendarModel {
setup(params, { dialog }) {
setup(params, { dialog, rpc }) {
super.setup(...arguments);
this.dialog = dialog;
this.rpc = rpc;
onWillStart(async () => {
this.credentialStatus = await this.rpc("/calendar/check_credentials");
});
}
get attendees() {
@@ -29,7 +29,7 @@ export class AttendeeCalendarCommonPopover extends CalendarCommonPopover {
accepted: "text-success",
declined: "text-danger",
tentative: "text-muted",
needsAction: "text-dark",
needsAction: "false",
};
this.statusInfo = {};
for (const selection of stateSelections) {
@@ -24,22 +24,19 @@
</xpath>
<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 ms-1'">
<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>
<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>
@@ -1,18 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.AttendeeCalendarCommonRenderer.event">
<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-if="!isAllDay">
<t t-if="24 > duration and duration > 0.5">
<br/><t t-esc="startTime"/> - <t t-esc="endTime"/>
<div class="fc-content" t-att-class="isMonth ? 'd-flex gap-1 text-truncate' : ''">
<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>
<t t-else="">, <t t-esc="startTime"/></t>
</t>
</div>
<span t-if="!isTimeHidden" class="fc-time" t-esc="startTime" />
<span t-if="!isTimeHidden and !isMonth" class="fc-time" t-esc="startTime" />
</div>
</t>
</templates>
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="calendar.CalendarQuickCreateButtons" t-inherit="web.FormViewDialog.ToOne.buttons">
<xpath expr="//button[contains(@class, 'o_form_button_cancel')]" position="after">
<button class="btn btn-secondary" t-on-click="goToFullEvent">More Options</button>
<button class="btn btn-link ms-auto" t-on-click="goToFullEvent">More Options</button>
</xpath>
</t>
</templates>
@@ -0,0 +1,7 @@
/** @odoo-module **/
import { TagsList } from "@web/core/tags_list/tags_list";
export class AttendeeTagsList extends TagsList {}
AttendeeTagsList.template = "calendar.AttendeeTagsList";
@@ -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;
}
}
@@ -0,0 +1,18 @@
<?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>
</t>
</templates>
@@ -6,7 +6,13 @@ import {
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 {
setup() {
super.setup();
@@ -28,7 +34,8 @@ export class Many2ManyAttendee extends Many2ManyTagsAvatarField {
const tags = super.tags.map((tag) => {
const partner = partnerIds.find((partner) => tag.resId === partner.id);
if (partner) {
tag.imageClass = `o_attendee_border o_attendee_border_${partner.status}`;
tag.status = partner.status;
tag.statusIcon = ICON_BY_STATUS[partner.status];
}
return tag;
});
@@ -46,6 +53,12 @@ export class Many2ManyAttendee extends Many2ManyTagsAvatarField {
}
}
Many2ManyAttendee.template = "calendar.Many2ManyAttendee";
Many2ManyAttendee.components = {
...Many2ManyAttendee.components,
TagsList: AttendeeTagsList,
};
export const many2ManyAttendee = {
...many2ManyTagsAvatarField,
component: Many2ManyAttendee,
@@ -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('many2many_tags_avatar_field_container')]" position="attributes">
<attribute name="class" add="flex-column" separator=" "/>
</xpath>
<xpath expr="//Many2XAutocomplete" position="attributes">
<attribute name="placeholder">props.placeholder</attribute>
</xpath>
</t>
</templates>
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="calendar.WeekDays">
<div class="o_recurrent_weekdays mt-2 mb-2 d-flex">
<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] ? 'btn-primary' : 'btn-secondary'}}" t-on-click="() => this.onChange(day)">
<div t-attf-class="o_calendar_week_days_rounded {{data[day] ? 'text-bg-action' : 'btn-secondary'}}" t-on-click="() => this.onChange(day)">
<t t-esc="props.record.fields[day].string[0]"/>
</div>
</t>
@@ -1,15 +1,8 @@
/** @odoo-module **/
import {
getFixture,
patchDate,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { getFixture, patchDate, patchWithCleanup } from "@web/../tests/helpers/utils";
import {
changeScale,
clickEvent,
} from "@web/../tests/views/calendar/helpers";
import { changeScale, clickEvent } from "@web/../tests/views/calendar/helpers";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
@@ -27,7 +20,7 @@ QUnit.module("CalendarView", ({ beforeEach }) => {
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => { },
clearTimeout: () => {},
});
target = getFixture();
@@ -93,7 +86,7 @@ QUnit.module("CalendarView", ({ beforeEach }) => {
},
is_hatched: { string: "Hatched", type: "boolean" },
is_striked: { string: "Striked", type: "boolean" },
res_model_name: { string: "Linked Model Name", type: "char" }
res_model_name: { string: "Linked Model Name", type: "char" },
},
records: [
{
@@ -124,7 +117,7 @@ QUnit.module("CalendarView", ({ beforeEach }) => {
methods: {
check_access_rights() {
return Promise.resolve(true);
}
},
},
},
user: {
@@ -171,8 +164,8 @@ QUnit.module("CalendarView", ({ beforeEach }) => {
{ id: 2, user_id: uid, partner_id: 2, partner_checked: true },
{ id: 3, user_id: 4, partner_id: 3, partner_checked: true },
],
}
}
},
},
};
});
@@ -188,8 +181,7 @@ QUnit.module("CalendarView", ({ beforeEach }) => {
stop: "2016-12-11 10:00:00",
allday: false,
partner_ids: [1, 2, 3],
res_model_name: 'Time Off',
res_model_name: "Time Off",
});
await makeView({
@@ -222,6 +214,8 @@ QUnit.module("CalendarView", ({ beforeEach }) => {
return Promise.resolve(true);
} else if (route === "/web/dataset/call_kw/res.partner/get_attendee_detail") {
return Promise.resolve([]);
} else if (route === "/calendar/check_credentials") {
return Promise.resolve({});
}
},
});
@@ -230,13 +224,9 @@ QUnit.module("CalendarView", ({ beforeEach }) => {
".o_calendar_renderer .fc-view-container",
"should instance of fullcalendar"
);
await changeScale(target, 'week');
await changeScale(target, "week");
await clickEvent(target, 3);
assert.containsOnce(
target,
".fa-link",
"A link icon should be present"
);
assert.containsOnce(target, ".fa-link", "A link icon should be present");
assert.strictEqual(target.querySelector("li a[href='#']").textContent, "Time Off");
});
});
@@ -102,8 +102,8 @@ QUnit.module(
"the tag should be correctly named"
);
assert.hasClass(
badges[0].querySelector('img'),
"o_attendee_border_accepted",
badges[0].querySelector(".attendee_tag_status"),
"o_attendee_status_accepted",
"Jesus should attend the meeting"
);
assert.strictEqual(
@@ -112,8 +112,8 @@ QUnit.module(
"the tag should be correctly named"
);
assert.hasClass(
badges[1].querySelector('img'),
"o_attendee_border_tentative",
badges[1].querySelector(".attendee_tag_status"),
"o_attendee_status_tentative",
"Mohamet should still confirm his attendance to the meeting"
);
assert.containsOnce(badges[0], "img", "should have img tag");
@@ -1,156 +1,161 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_service/tour_utils";
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_service/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`;
};
return `${month}/${day}/${year} 10:00:00`;
};
registry.category("web_tour.tours").add('calendar_appointments_hour_tour', {
url: '/web',
test: true,
steps: () => [stepUtils.showAppsMenuItem(),
registry.category("web_tour.tours").add("calendar_appointments_hour_tour", {
url: "/web",
test: true,
steps: () => [
stepUtils.showAppsMenuItem(),
{
trigger: '.o_app[data-menu-xmlid="calendar.mail_menu_calendar"]',
content: 'Open Calendar',
run: 'click',
content: "Open Calendar",
run: "click",
},
{
trigger: '.o-calendar-button-new',
content: 'Create a new event',
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: 'text TEST EVENT',
trigger: "#name_0",
content: "Give a name to the new event",
run: "text TEST EVENT",
},
{
trigger: '#start_0',
content: 'Give a date to the new event',
trigger: "#start_0",
content: "Give a date to the new event",
run: `text ${todayDate()}`,
},
{
trigger: '.fa-cloud-upload',
content: 'Save the new event',
run: 'click',
trigger: ".fa-cloud-upload",
content: "Save the new event",
run: "click",
},
{
trigger: '.o_back_button',
content: 'Go back to Calendar view',
run: 'click',
trigger: ".o_back_button",
content: "Go back to Calendar view",
run: "click",
},
{
trigger: '.dropdown-toggle:contains("Week")',
content: 'Click to change calendar view',
run: 'click',
content: "Click to change calendar view",
run: "click",
},
{
trigger: '.dropdown-item:contains("Month")',
content: 'Change the calendar view to Month',
run: 'click',
content: "Change the calendar view to Month",
run: "click",
},
{
trigger: '.fc-day-header:contains("Monday")',
content: 'Change the calendar view to week',
trigger: '.fc-day-header:contains("Mon")',
content: "Check the day is properly displayed",
},
{
trigger: '.fc-time:contains("10:00")',
content: 'Check the time is properly displayed',
content: "Check the time is properly displayed",
},
{
trigger: '.o_event_title:contains("TEST EVENT")',
content: 'Check the event title',
content: "Check the event title",
isCheck: true,
},
]});
],
});
registry.category("web_tour.tours").add('test_calendar_delete_tour', {
test: true,
steps: () => [
registry.category("web_tour.tours").add("test_calendar_delete_tour", {
test: true,
steps: () => [
{
content: 'Select filter (everybody)',
content: "Select filter (everybody)",
trigger: 'div[data-value="all"] input',
},
{
content: 'Click on the event (focus + waiting)',
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: "Delete the event",
trigger: ".o_cw_popover_delete",
},
{
content: 'Validate the deletion',
trigger:'button:contains("Delete")',
content: "Validate the deletion",
trigger: 'button:contains("Delete")',
async run() {
$('button:contains("Delete")').click();
await new Promise((r) => setTimeout(r, 1000));
}
},
},
]});
registry.category("web_tour.tours").add('test_calendar_decline_tour', {
test: true,
steps: () => [
],
});
registry.category("web_tour.tours").add("test_calendar_decline_tour", {
test: true,
steps: () => [
{
content: 'Click on the event (focus + waiting)',
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: "Delete the event",
trigger: ".o_cw_popover_delete",
},
{
content: 'Wait declined status',
trigger: '.o_attendee_status_declined',
content: "Wait declined status",
trigger: ".o_attendee_status_declined",
isCheck: true,
},
]});
registry.category("web_tour.tours").add('test_calendar_decline_with_everybody_filter_tour', {
test: true,
steps: () => [
],
});
registry.category("web_tour.tours").add("test_calendar_decline_with_everybody_filter_tour", {
test: true,
steps: () => [
{
content: 'Select filter (everybody)',
content: "Select filter (everybody)",
trigger: 'div[data-value="all"] input',
},
{
content: 'Click on the event (focus + waiting)',
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: "Delete the event",
trigger: ".o_cw_popover_delete",
},
{
content: 'Select filter (everybody)',
content: "Select filter (everybody)",
trigger: 'div[data-value="all"] input',
},
{
content: 'Wait declined status',
trigger: '.o_attendee_status_declined',
content: "Wait declined status",
trigger: ".o_attendee_status_declined",
isCheck: true,
},
]});
],
});
+113 -118
View File
@@ -126,111 +126,42 @@
</div>
<group>
<group>
<field name="start_date" string="from" invisible="not allday" readonly="not user_can_edit" required="allday" force_save="1"/>
<field name="stop_date" string="to" invisible="not allday" readonly="not user_can_edit" required="allday" force_save="1"/>
<field name="start" string="from" invisible="allday" readonly="not user_can_edit" required="not allday"/>
<field name="stop" string="to" invisible="allday" readonly="not user_can_edit" required="not allday"/>
<label for="duration" invisible="allday"/>
<div invisible="allday">
<field name="duration" widget="float_time" string="Duration" class="oe_inline" readonly="(id and recurrency) or not user_can_edit"/>
<span> hours</span>
<field name="start_date" widget="daterange" options="{'end_date_field': 'stop_date'}" invisible="not allday" required="allday"/>
<field name="start" widget="daterange" options="{'end_date_field': 'stop'}" invisible="allday" required="not allday"/>
<field name="stop_date" invisible="1" />
<field name="stop" invisible="1" />
<div class="d-flex gap-2" colspan="2">
<div class="d-flex align-items-start" invisible="allday">
<label for="duration" class="pe-2 fw-bold text-900" invisible="allday"/>
<field name="duration" widget="float_time" string="Duration" class="oe_inline pe-2" readonly="(id and recurrency) or not user_can_edit"/>
<span> hours</span>
</div>
<div>
<label for="allday" class="pe-2 fw-bold text-900"/>
<field name="allday" class="oe_inline" force_save="1" readonly="not user_can_edit" colspan="1"/>
</div>
</div>
<field name="event_tz" invisible="not recurrency" readonly="not user_can_edit"/>
<field name="allday" force_save="1" readonly="not user_can_edit"/>
</group>
<group>
<field name="alarm_ids" widget="many2many_tags" options="{'no_quick_create': True}"/>
<field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
</group>
</group>
<notebook>
<page name="page_details" string="Information">
<group>
<group>
<field name="location" />
<label for="videocall_location" class="opacity-100"/>
<div col="2">
<field name="videocall_location" string="Videocall URL" widget="CopyClipboardChar" force_save="1" readonly="videocall_source == 'discuss'"/>
<button name="clear_videocall_location" type="object" class="btn btn-link"
invisible="not videocall_location" context="{'recurrence_update': recurrence_update}">
<span class="fa fa-times"></span><span> Clear meeting</span>
</button>
<button name="set_discuss_videocall_location" type="object" class="btn btn-link"
invisible="videocall_location" context="{'recurrence_update': recurrence_update}">
<span class="fa fa-plus"></span><span> Add Odoo meeting</span>
</button>
<button name="action_join_video_call" class="btn btn-link" help="Join Video Call" type="object" invisible="not videocall_location">
<span class="oi oi-arrow-right"></span><span> Join video call</span>
</button>
</div>
<field name="videocall_source" invisible="1"/>
<field name="access_token" invisible="1" force_save="1"/>
<label for="privacy"/>
<div class="o_row">
<field name="show_as" readonly="not user_can_edit" nolabel="1"/>
<field name="privacy" readonly="not user_can_edit" nolabel="1"/>
</div>
<field name="user_id" widget="many2one_avatar_user" readonly="not user_can_edit"/>
<field name="description" options="{'height': 100}"/>
</group>
<group>
<div class="o_row" colspan="2">
<div colspan="1">
<field name="attendees_count" class="w-auto oe_inline" nolabel="1"/><span> Attendees</span>
</div>
<div name="send_buttons" class="sm-2" colspan="1">
<button name="action_open_composer" help="Send Email to attendees" type="object" string=" EMAIL" icon="fa-envelope"/>
</div>
</div>
<div colspan="2">
<field name="accepted_count" class="w-auto oe_inline" nolabel="1"/> yes,
<field name="tentative_count" class="w-auto oe_inline" nolabel="1"/> maybe,
<field name="declined_count" class="w-auto oe_inline" nolabel="1"/> no,
<field name="awaiting_count" class="w-auto oe_inline" nolabel="1"/> awaiting
</div>
<div class="d-flex align-items-baseline">
<field name="partner_ids" widget="many2manyattendee"
placeholder="Select attendees..."
context="{'force_email':True}"
domain="[('type','!=','private')]"
class="oe_inline"
readonly="not user_can_edit"
/>
</div>
<div class="alert alert-warning o_form_header mt-2" invisible="not invalid_email_partner_ids" role="status">
<p><strong>The following attendees have invalid email addresses and won't receive any email notifications:</strong></p>
<field name="invalid_email_partner_ids" widget="many2manyattendee" class="oe_inline"/>
</div>
</group>
</group>
</page>
<page name="page_options" string="Options">
<group>
<field name="recurrence_id" invisible="1" />
<field name="rrule_type" invisible="1" />
<group class="mb-0">
<field name="recurrency" />
<field name="rrule_type_ui" class="w-auto" invisible="not recurrency" required="recurrency"/>
</group>
<newline />
<group invisible="not recurrency">
<group class="o_row" invisible="rrule_type_ui != 'custom'">
<field name="interval" string="Repeat every" class="oe_inline w-auto" />
<field name="rrule_type" nolabel="1" class="oe_inline" required="rrule_type_ui == 'custom'"/>
</group>
</group>
<newline />
<group class="gap-0" invisible="rrule_type_ui not in ['weekly', 'custom'] or (rrule_type_ui == 'custom' and rrule_type != 'weekly')">
<span class="fw-bold pe-3">Repeat on</span>
<widget name="calendar_week_days" />
</group>
<group>
<field name="recurrence_id" invisible="1" />
</group>
</group>
<div class="d-flex gap-2" invisible="rrule_type_ui not in ['monthly', 'custom'] or (rrule_type_ui == 'custom' and rrule_type != 'monthly')">
<field name="recurrence_id" invisible="1" />
<field name="rrule_type" invisible="1" />
<field name="recurrency" />
<field name="rrule_type_ui" class="w-auto" invisible="not recurrency" required="recurrency"/>
<div invisible="not recurrency" colspan="2">
<div class="o_row" invisible="rrule_type_ui != 'custom'">
<label for="interval" class="fw-bold text-900"/>
<field name="interval" string="Repeat every" class="oe_inline w-auto" />
<field name="rrule_type" nolabel="1" class="oe_inline" required="rrule_type_ui == 'custom'"/>
</div>
</div>
<div class="d-flex gap-0" colspan="2" invisible="rrule_type_ui not in ['weekly', 'custom'] or (rrule_type_ui == 'custom' and rrule_type != 'weekly')">
<span class="fw-bold pe-3">Repeat on</span>
<widget name="calendar_week_days" />
</div>
<field name="recurrence_id" invisible="1" />
<div class="d-flex gap-2" colspan="2" invisible="rrule_type_ui not in ['monthly', 'custom'] or (rrule_type_ui == 'custom' and rrule_type != 'monthly')">
<label string="Day of Month" for="month_by" />
<field name="month_by" nolabel="1" required="rrule_type_ui == 'monthly' or rrule_type == 'monthly'"/>
<field name="day" nolabel="1"
@@ -246,16 +177,76 @@
invisible="month_by != 'day'"
/>
</div>
<div class="d-flex gap-2" invisible="not recurrency">
<label string="Until" for="end_type" />
<div class="d-flex gap-2" colspan="2" invisible="not recurrency">
<label string="Until" for="end_type" class="fw-bold text-900"/>
<field name="end_type" class="oe_inline w-auto" nolabel="1" required="recurrency"/>
<field name="count" class="oe_inline w-auto" nolabel="1" invisible="end_type != 'count'" required="recurrency"/>
<field name="until" class="oe_inline w-auto" nolabel="1" invisible="end_type != 'end_date'"
required="recurrency and end_type == 'end_date'"
/>
</div>
</page>
<field name="location" placeholder="Online Meeting"/>
<label for="videocall_location" class="opacity-100"/>
<div col="2">
<field name="videocall_location" string="Videocall URL" widget="CopyClipboardChar" force_save="1" readonly="videocall_source == 'discuss'"/>
<button name="clear_videocall_location" type="object" class="btn btn-link ps-0"
invisible="not videocall_location" context="{'recurrence_update': recurrence_update}">
<span class="fa fa-times"></span><span> Clear meeting</span>
</button>
<button name="set_discuss_videocall_location" type="object" class="btn btn-link ps-0"
invisible="videocall_location" context="{'recurrence_update': recurrence_update}">
<span class="fa fa-plus"></span><span> Odoo meeting</span>
</button>
<button name="action_join_video_call" class="btn btn-link" help="Join Video Call" type="object" invisible="not videocall_location">
<span class="oi oi-arrow-right"></span><span> Join video call</span>
</button>
</div>
<field name="videocall_source" invisible="1"/>
<field name="access_token" invisible="1" force_save="1"/>
<field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
<label for="privacy"/>
<div class="o_row">
<field name="show_as" readonly="not user_can_edit" nolabel="1"/>
<field name="privacy" readonly="not user_can_edit" nolabel="1"/>
</div>
<field name="user_id" widget="many2one_avatar_user" readonly="not user_can_edit"/>
<field name="description" options="{'height': 100}" placeholder="Add description"/>
</group>
<group>
<field name="should_show_status" invisible="1"/>
<field name="current_status" class="w-auto" required="should_show_status" readonly="not should_show_status" invisible="not should_show_status"/>
<field name="alarm_ids" widget="many2many_tags" options="{'no_quick_create': True}"/>
<div class="o_row" colspan="2">
<div colspan="1">
<field name="attendees_count" class="w-auto oe_inline" nolabel="1"/><span> Attendees</span>
</div>
<div name="send_buttons" class="d-flex gap-2 justify-content-end" colspan="1">
<button name="action_open_composer" help="Send Email to attendees" type="object" string=" EMAIL" icon="fa-envelope"/>
</div>
</div>
<div colspan="2">
<field name="accepted_count" class="w-auto oe_inline" nolabel="1"/> yes,
<field name="tentative_count" class="w-auto oe_inline" nolabel="1"/> maybe,
<field name="declined_count" class="w-auto oe_inline" nolabel="1"/> no,
<field name="awaiting_count" class="w-auto oe_inline" nolabel="1"/> awaiting
</div>
<div class="d-flex align-items-baseline" colspan="2">
<field name="partner_ids" widget="many2manyattendee"
placeholder="Select attendees..."
context="{'force_email':True}"
domain="[('type','!=','private')]"
class="oe_inline"
readonly="not user_can_edit"
/>
</div>
<div class="alert alert-warning o_form_header mt-2" invisible="not invalid_email_partner_ids" role="status">
<p><strong>The following attendees have invalid email addresses and won't receive any email notifications:</strong></p>
<field name="invalid_email_partner_ids" widget="many2manyattendee" class="oe_inline"/>
</div>
</group>
</group>
<notebook>
<page name="page_invitations" string="Invitations" groups="base.group_no_one" invisible="not user_can_edit">
<button name="action_sendmail" type="object" string="Send Invitations" icon="fa-envelope" class="oe_link"/>
<field name="attendee_ids" widget="one2many" mode="tree,kanban" readonly="1">
@@ -315,15 +306,11 @@
<h1 class="w-100"><field name="name" nolabel="1" placeholder="Add title" colspan="2"/></h1>
</div>
<group>
<field name="start_date" string="From" invisible="not allday" required="allday"/>
<field name="stop_date" string="To" invisible="not allday" required="allday"/>
<field name="start" string="From" invisible="allday" required="not allday" options="{'block': True, 'icon': 'fa fa-users'}"/>
<field name="stop" string="To" invisible="allday"/>
</group>
<group>
<field name="start_date" widget="daterange" options="{'end_date_field': 'stop_date'}" invisible="not allday" required="allday"/>
<field name="start" widget="daterange" options="{'end_date_field': 'stop'}" invisible="allday" required="not allday"/>
<field name="stop_date" invisible="1" />
<field name="stop" invisible="1" />
<field name="allday"/>
</group>
<group>
<field name="partner_ids" widget="many2manyattendee"
placeholder="Add attendees..."
context="{'force_email':True}"
@@ -332,19 +319,19 @@
<label for="videocall_location" class="opacity-100"/>
<div col="2">
<field name="videocall_location" string="Videocall URL" widget="CopyClipboardChar" force_save="1" readonly="videocall_source == 'discuss'"/>
<button name="clear_videocall_location" type="object" class="btn btn-link"
<button name="clear_videocall_location" type="object" class="btn btn-link p-0"
invisible="not videocall_location" context="{'recurrence_update': recurrence_update}">
<span class="fa fa-times"></span><span> Clear meeting</span>
</button>
<button name="set_discuss_videocall_location" type="object" class="btn btn-link"
<button name="set_discuss_videocall_location" type="object" class="btn btn-link p-0"
invisible="videocall_location" context="{'recurrence_update': recurrence_update}">
<span class="fa fa-plus"></span><span> Add Odoo meeting</span>
<span class="fa fa-plus"></span> <span>Odoo meeting</span>
</button>
<button name="action_join_video_call" class="btn btn-link" help="Join Video Call" type="object" invisible="not videocall_location">
<span class="oi oi-arrow-right"></span><span> Join video call</span>
</button>
</div>
<field name="description"/>
<field name="description" placeholder="Describe your meeting"/>
</group>
</sheet>
</form>
@@ -356,7 +343,7 @@
<field name="model">calendar.event</field>
<field name="priority" eval="2"/>
<field name="arch" type="xml">
<calendar js_class="attendee_calendar" string="Meetings" date_start="start" date_stop="stop" date_delay="duration" all_day="allday"
<calendar js_class="attendee_calendar" string="Meetings" banner_route="/onboarding/calendar" date_start="start" date_stop="stop" date_delay="duration" all_day="allday"
event_open_popup="true"
event_limit="5"
quick_create="true"
@@ -475,6 +462,14 @@
<field name="view_id" ref="view_calendar_event_form"/>
</record>
<record id="action_view_start_calendar_sync" model="ir.actions.act_window">
<field name="name">Connect your Calendar</field>
<field name="res_model">calendar.provider.config</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'dialog_size': 'medium'}</field>
</record>
<!-- Menus -->
<menuitem
id="mail_menu_calendar"
+12 -2
View File
@@ -4,12 +4,13 @@
from odoo import http
from odoo.http import request
from odoo.addons.google_calendar.utils.google_calendar import GoogleCalendarService
from odoo.addons.calendar.controllers.main import CalendarController
class GoogleCalendarController(http.Controller):
class GoogleCalendarController(CalendarController):
@http.route('/google_calendar/sync_data', type='json', auth='user')
def sync_data(self, model, **kw):
def google_calendar_sync_data(self, model, **kw):
""" This route/function is called when we want to synchronize Odoo
calendar with Google Calendar.
Function return a dictionary with the status : need_config_from_admin, need_auth,
@@ -57,3 +58,12 @@ class GoogleCalendarController(http.Controller):
}
return {"status": "success"}
@http.route()
def check_calendar_credentials(self):
res = super().check_calendar_credentials()
get_param = request.env['ir.config_parameter'].sudo().get_param
client_id = get_param('google_calendar_client_id')
client_secret = get_param('google_calendar_client_secret')
res['google_calendar'] = bool(client_id and client_secret)
return res
@@ -1,11 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="google_calendar.GoogleCalendarController" t-inherit="calendar.AttendeeCalendarController" t-inherit-mode="extension">
<xpath expr="//div[@id='calendar_sync_wrapper']" position="attributes">
<attribute name="t-if">true</attribute>
</xpath>
<xpath expr="//div[@id='google_calendar_sync']" position="replace">
<div id="google_calendar_sync" class="o_calendar_sync">
<FilterPanel position="after">
<div id="google_calendar_sync" class="o_calendar_sync" t-if="model.googleCredentialsSet">
<button t-if="!model.googleIsSync and model.googleIsPaused" type="button" id="google_sync_paused" class="o_google_sync_button o_google_sync_paused btn btn-warning" t-on-click="onGoogleSyncUnpause">
<b>
<i id="google_paused" class='fa fa-pause'/>
@@ -26,6 +23,6 @@
</b>
</button>
</div>
</xpath>
</FilterPanel>
</t>
</templates>
@@ -59,4 +59,8 @@ patch(AttendeeCalendarModel.prototype, {
this.googlePendingSync = false;
return result;
},
get googleCredentialsSet() {
return this.credentialStatus['google_calendar'] ?? false;
}
});
@@ -128,6 +128,10 @@ QUnit.module('Google Calendar', {
return Promise.resolve([]);
} else if (route === '/web/dataset/call_kw/res.users/has_group') {
return Promise.resolve(true);
} else if (route === '/calendar/check_credentials') {
return Promise.resolve({
google_calendar: true,
});
}
},
});
@@ -538,7 +538,7 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
'reminders': {'useDefault': True},
"attendees": [
{
"email": "odoobot@example.com", "state": "accepted",
"email": "odoobot@example.com", "responseStatus": "accepted",
},
],
'updated': self.now,
@@ -582,7 +582,7 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
'reminders': {'useDefault': True},
"attendees": [
{
"email": "odoobot@example.com", "state": "accepted",
"email": "odoobot@example.com", "responseStatus": "accepted",
},
],
'updated': self.now,
@@ -635,7 +635,7 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
'reminders': {'useDefault': True},
"attendees": [
{
"email": "odoobot@example.com", "state": "accepted",
"email": "odoobot@example.com", "responseStatus": "accepted",
},
],
'updated': self.now,
@@ -685,7 +685,7 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
'reminders': {'useDefault': True},
"attendees": [
{
"email": "odoobot@example.com", "state": "accepted",
"email": "odoobot@example.com", "responseStatus": "accepted",
},
],
'updated': self.now,
@@ -1375,7 +1375,7 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
"attendees": [
{
"email": mail_alias.display_name,
"state": "accepted",
"responseStatus": "accepted",
},
],
'updated': self.now,
+3 -3
View File
@@ -1025,9 +1025,9 @@ class HolidaysRequest(models.Model):
'res_id': holiday.id,
}
# Add the partner_id (if exist) as an attendee
if user and user.partner_id:
meeting_values['partner_ids'] = [
(4, user.partner_id.id)]
partner_id = (user and user.partner_id) or (holiday.employee_id and holiday.employee_id.work_contact_id)
if partner_id:
meeting_values['partner_ids'] = [(4, partner_id.id)]
result[user.id].append(meeting_values)
return result
@@ -4,10 +4,7 @@
justify-content: start;
flex: 1;
text-align: center;
&:not(:last-child) {
border-right: 2px solid $border-color;
}
border-right: 1px solid $border-color;
&>* {
width: fit-content;
@@ -1,7 +1,9 @@
.o_timeoff_calendar {
.o_calendar_renderer {
height: unset;
background-color: $o-view-background-color;
flex-grow: 1;
flex-basis: fit-content;
.o_calendar_renderer {
@for $size from 1 through length($o-colors) {
.o_calendar_widget {
.hr_mandatory_day_top_#{$size - 1}:not(.fc-disabled-day) {
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="hr_holidays.CalendarRenderer">
<div class="o_timeoff_calendar">
<div class="o_timeoff_calendar d-flex flex-column">
<TimeOffDashboard t-if="showDashboard" employeeId="employeeId"/>
<t t-call="web.CalendarRenderer"/>
</div>
@@ -1,10 +1,7 @@
.o_calendar_filter {
span {
vertical-align: middle;
}
& img {
width:30px;
img:not(.o_avatar) {
width: 30px;
&.o_calendar_filter_plain {
content:var(--calendarFilter-icon--plain);
@@ -19,23 +16,6 @@
}
}
.o_timeoff_legend {
display: inline-block;
width: 24px;
height: 30px;
margin: 0 3px;
padding: 3px 0;
text-align: center;
&_bankholiday {
background-color: $gray-200;
}
&_mandatoryday {
font-weight: 600;
}
}
@for $size from 1 through length($o-colors) {
.hr_mandatory_day_#{$size - 1}:not(.fc-disabled-day) {
color: nth($o-colors, $size) !important;
@@ -3,12 +3,12 @@
<t t-name="hr_holidays.CalendarFilterPanel" t-inherit="web.CalendarFilterPanel" t-inherit-mode="primary">
<xpath expr="//t[@t-foreach='props.model.filterSections']" position="after">
<div class="o_calendar_filter">
<div class="o_calendar_filter mt-4">
<h5>Legend</h5>
<div class="d-flex flex-column">
<span><img class="o_calendar_filter_plain" src="/hr/static/src/img/icons/plain.svg"/> Validated</span>
<span><img class="o_calendar_filter_hatched" src="/hr/static/src/img/icons/hatched.svg"/> To Approve</span>
<span><img class="o_calendar_filter_line" src="/hr/static/src/img/icons/line.svg"/> Refused</span>
<span class="align-middle"><img class="o_calendar_filter_plain" src="/hr/static/src/img/icons/plain.svg"/> Validated</span>
<span class="align-middle"><img class="o_calendar_filter_hatched" src="/hr/static/src/img/icons/hatched.svg"/> To Approve</span>
<span class="align-middle"><img class="o_calendar_filter_line" src="/hr/static/src/img/icons/line.svg"/> Refused</span>
</div>
<div class="d-flex flex-column mt-4" t-if="leaveState.mandatoryDays.length">
@@ -40,7 +40,7 @@
<t t-name="hr_holidays.CalendarFilterPanel.filter" t-inherit="web.CalendarFilterPanel.filter" t-inherit-mode="primary">
<xpath expr="//span[@t-esc='filter.label']" position="replace">
<span class="o_cw_filter_title text-truncate flex-grow">
<span class="o_cw_filter_title flex-grow-1 text-truncate lh-base">
<t t-esc="filter.label"/>
<t t-if="env.isSmall">
@@ -44,6 +44,7 @@ export class TimeOffCalendarYearRenderer extends CalendarYearRenderer {
weekNumbers: true,
weekNumbersWithinDays: false,
weekLabel: _t("Week"),
firstDay: 0,
});
}
@@ -1,14 +0,0 @@
.o_timeoff_calendar {
.o_calendar_widget {
.fc-dayGridYear-view {
.fc-week-number {
color: #adb5bd;
font-size: 0.85rem;
cursor: default;
vertical-align: middle;
line-height: unset;
}
}
}
}
@@ -3,39 +3,13 @@
import { AttendeeCalendarCommonRenderer } from "@calendar/views/attendee_calendar/common/attendee_calendar_common_renderer";
import { patch } from "@web/core/utils/patch";
import { renderToString } from "@web/core/utils/render";
const { DateTime } = luxon;
patch(AttendeeCalendarCommonRenderer.prototype, {
get options(){
let a = Object.assign(super.options, {
columnHeaderHtml: function(date) {
const datetime = DateTime.fromJSDate(date);
if (this.props.model.scale === 'week'){
return (
"<div>" +
datetime.toFormat("ccc dd") +
"</div>" +
renderToString(this.constructor.HeaderCalendarTemplate, {
date: date,
today: new Date(),
})
);
}
if (this.props.model.scale === 'day'){
return (
"<div>" +
datetime.toFormat("LLLL dd, yyyy") +
"</div>" +
renderToString(this.constructor.HeaderCalendarTemplate, {
date: date,
today: new Date(),
})
);
}
return "<div>" + datetime.toFormat("cccc") + "</div>";
}
});
return Object.assign(a, {
return {
...super.options,
eventOrder: function(event1, event2){
if (event1.extendedProps.worklocation){
return -1;
@@ -46,8 +20,8 @@ patch(AttendeeCalendarCommonRenderer.prototype, {
return event1.title.localeCompare(event2.title);
}
}
}
})
},
};
},
fcEventToRecord(event) {
const res = super.fcEventToRecord(...arguments);
@@ -119,8 +93,8 @@ patch(AttendeeCalendarCommonRenderer.prototype, {
onDayRender(info){
const parsedDate = DateTime.fromJSDate(info.date).toISODate();
if (this.props.model.scale === 'week' || this.props.model.scale === 'day'){
const button = info.view.context.calendar.el.querySelector(`.fc-day-header[data-date='${parsedDate}'] .o_month`)
const line = info.view.context.calendar.el.querySelector(`.fc-day-header[data-date='${parsedDate}'] .line`)
const button = info.view.context.calendar.el.querySelector(`.fc-day-header[data-date='${parsedDate}'] o_worklocation_text`)
const line = info.view.context.calendar.el.querySelector(`.fc-day-header[data-date='${parsedDate}'] .o_worklocation_line`)
if (!button || !line)
return;
info.homework = true;
@@ -133,7 +107,8 @@ patch(AttendeeCalendarCommonRenderer.prototype, {
return;
const content = renderToString(this.constructor.ButtonWorklocationTemplate, {})
const {children } = new DOMParser().parseFromString(content, "text/html").body
box.appendChild(...children)
box.appendChild(...children);
info.homework = true;
}
super.onDayRender(...arguments);
},
@@ -205,7 +180,7 @@ patch(AttendeeCalendarCommonRenderer.prototype, {
}
},
onDateClick(info){
if (info.jsEvent && info.jsEvent.target.tagName ==="BUTTON") {
if (info.jsEvent && info.jsEvent.target.closest(".o_worklocation_btn")) {
info.homework = true
this.props.createRecord(this.fcEventToRecord(info));
} else {
@@ -215,5 +190,5 @@ patch(AttendeeCalendarCommonRenderer.prototype, {
});
AttendeeCalendarCommonRenderer.WorklocationTemplate = "hr.homeworking.CalendarCommonRenderer.worklocation";
AttendeeCalendarCommonRenderer.HeaderCalendarTemplate = "hr.homeworking.CalendarCommonRenderer.buttonWorklocation";
AttendeeCalendarCommonRenderer.ButtonWorklocationTemplate = "hr.homeworking.CalendarCommonRenderer.buttonWorklocation";
AttendeeCalendarCommonRenderer.headerTemplate = "hr_homeworking.CalendarCommonRendererHeader";
@@ -1,29 +1,17 @@
.btnWorklocation{
display:flex ;
align-items: center;
opacity: 0;
&:hover {
opacity: 100;
.o_worklocation_btn {
margin: 2px;
.o_worklocation_text, .o_worklocation_line {
box-shadow: 0 1px 0 rgba(0,0,0, 0.3)
}
.o_month{
box-shadow: 0.1em 0.1em 0.1em rgb(131, 126, 126);
border-radius: 100px;
font-size: 0.75em;
width: 50%;
border-color: transparent;
}
.line{
box-shadow: 0.1em 0.1em 0.1em rgb(131, 126, 126);
border: none;
border-color: transparent;
height: 0.3px;
width:100%;
.o_worklocation_text {
font-size: $o-font-size-base-smaller;
}
}
.fc-event {
box-shadow: none !important;
.o_worklocation_line {
height: 3px;
}
.fc-event.o_homework_event {
@@ -31,59 +19,59 @@
border-color: transparent;
box-shadow: none;
cursor: pointer;
.o_homework_single {
display:flex;
align-items: center;
width: 100%;
.fc-content {
margin: 0 !important;
padding: 3px 6px 3px 6px;
border-radius: 100px;
width: auto;
overflow: visible;
opacity: 1;
font-weight: 600;
}
.line{
height: 0.3em;
width: 100%;
}
}
.o_homework_multi {
display: flex;
.fc-content .fa-2x {
display: flex;
align-content: center;
flex-wrap: wrap;
justify-content: center;
height: 20px;
width: 20px;
border-radius: 100%;
font-size: 1.2em;
&.fa-building {
font-size: 1.0em;
}
.o_homework_multi .o_homeworking_content .fa {
height: var(--o-homework-icon-size, 20px);
aspect-ratio: 1;
font-size: var(--o-homework-icon-size, 1.2rem);
&.fa-building:before {
transform: scale(.8);
}
}
}
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
.o_popover{
.wl_color_#{$i - 1} {
color: $color;
// ----- View specific
.fc-timeGridDay-view {
.o_worklocation_btn .o_worklocation_line{
display: none;
}
}
@include media-breakpoint-down(md) {
.fc-timeGridWeek-view .o_homework_multi {
--o-homework-icon-size: 1rem;
> span {
display: none !important;
}
}
.fc-dayGridMonth-view .o_homework_multi {
--o-homework-icon-size: .75rem;
> span {
display: none !important;
}
}
}
@for $-i from 1 through length($o-colors-complete) {
$-color: nth($o-colors-complete, $-i);
$-color-subtle: tint-color($-color, 65%);
.o_homework_multi {
.wl_color_#{$i - 1} {
color: $color;
background-color: scale-color($color, $lightness: 85%);
.wl_color_#{$-i - 1} {
color: shade-color($-color, 10%);
background-color: $-color-subtle;
border-color: $-color-subtle !important;
}
}
.o_homework_single {
.wl_color_#{$i - 1} {
background-color: scale-color($color, $lightness: 50%);
.wl_color_#{$-i - 1} {
background-color: $-color-subtle;
}
}
}
}
@@ -3,41 +3,47 @@
<templates>
<t t-name="hr.homeworking.CalendarCommonRenderer.worklocation">
<t t-if="multiCalendar">
<div class="o_homework_multi">
<div class="o_homework_multi d-flex align-items-center gap-1">
<t t-foreach="records" t-as="record" t-key="record.id">
<div class="fc-content">
<i class="o_homework_content me-0" t-att-class="`fa ${iconStr} fa-2x wl_color_${record.colorIndex}`" t-att-data-id="record.id"/>
<div class="o_homeworking_content">
<i class="o_homework_content d-flex align-items-center justify-content-center flex-wrap border border-1 rounded-circle me-0" t-att-class="`fa ${iconStr} wl_color_${record.colorIndex}`" t-att-data-id="record.id"/>
</div>
</t>
<span class="d-flex align-items-center fw-bolder" t-esc="records[0].title"/>
<span class="fw-bolder" t-esc="records[0].title"/>
</div>
</t>
<t t-else="">
<div class="o_homework_single">
<div class="fc-content text-nowrap" t-att-class="`wl_color_${colorIndex}`">
<div class="o_homework_single d-flex align-items-center w-100">
<div class="o_homeworking_content overflow-visible opacity-100 rounded-pill m-0 px-2 text-nowrap" t-att-class="`wl_color_${colorIndex}`">
<t t-if="icon === 'office'">
<div class="fa fa-building me-1"/>
<div class="fa fa-building me-lg-1"/>
</t>
<t t-elif="icon === 'home'">
<div class="fa fa-home me-1"/>
<div class="fa fa-home me-lg-1"/>
</t>
<t t-else="">
<div class="fa fa-map-marker me-1"/>
<div class="fa fa-map-marker me-lg-1"/>
</t>
<t class="fw-bolder" t-esc="title"/>
<span class="d-none d-lg-inline fw-bold" t-esc="title"/>
</div>
<span class="line" t-att-class="`wl_color_${colorIndex}`"/>
<span class="o_worklocation_line w-100" t-att-class="`wl_color_${colorIndex}`"/>
</div>
</t>
</t>
<t t-name="hr_homeworking.CalendarCommonRendererHeader" t-inherit="web.CalendarCommonRendererHeader" t-inherit-mode="primary">
<xpath expr="//span[hasclass('o_cw_day_number')]" position="after">
<t t-if="scale != 'month'" t-call="hr.homeworking.CalendarCommonRenderer.buttonWorklocation"/>
</xpath>
</t>
<t t-name="hr.homeworking.CalendarCommonRenderer.buttonWorklocation">
<div class="btnWorklocation w-100">
<button class='rounded-pill o_month text-nowrap bg-info-light'>
<i class="fa fa-map-marker" aria-hidden="true"></i>
Add Location
<div class="o_worklocation_btn d-flex align-items-center opacity-0 opacity-100-hover">
<button class='o_worklocation_text bg-info bg-opacity-25 border-0 rounded-pill px-lg-2 py-0 text-nowrap'>
<i class="fa fa-map-marker me-lg-1" aria-hidden="true"></i>
<span class="d-none d-lg-inline fw-bold">Add Location</span>
</button>
<button class="line bg-info-light"/>
<button class="o_worklocation_line d-none d-lg-block w-100 bg-info bg-opacity-25 border-0 p-0"/>
</div>
</t>
</templates>
</templates>
@@ -4,27 +4,25 @@
<t t-if="isWorkLocationEvent()">
<Record resModel="'hr.employee.location'" fieldNames="fieldNames" fields="fields" activeFields="fields" values="values" mode="'readonly'" t-slot-scope="slot">
<div class="card-header d-flex justify-content-between py-2 pe-2">
<h4 class="p-0 pt-1">
<span class="popover-header border-0">
<t t-if="slot.record.data.work_location_type === 'office'">
<i class="fa fa-fw fa-building fa-1x" role="img" t-att-class="`wl_color_${props.record.colorIndex}`"/>
</t>
<t t-elif="slot.record.data.work_location_type === 'home'">
<i class="fa fa-fw fa-home fa-1x" role="img" t-att-class="`wl_color_${props.record.colorIndex}`" />
</t>
<t t-else="">
<i class="fa fa-fw fa-map-marker fa-1x" t-att-class="`wl_color_${props.record.colorIndex}`" role="img"/>
</t>
<t t-esc="props.record.title"/>
</span>
</h4>
<span class="popover-header border-0">
<t t-if="slot.record.data.work_location_type === 'office'">
<i class="fa fa-fw fa-building me-2" role="img" t-att-class="`wl_color_${props.record.colorIndex}`"/>
</t>
<t t-elif="slot.record.data.work_location_type === 'home'">
<i class="fa fa-fw fa-home me-2" role="img" />
</t>
<t t-else="">
<i class="fa fa-fw fa-map-marker me-2" t-att-class="`wl_color_${props.record.colorIndex}`" role="img"/>
</t>
<t t-esc="props.record.title"/>
</span>
<span class="o_cw_popover_close ms-4 mt-2 me-2" t-on-click.stop="() => props.close()">
<i class="fa fa-close" />
<i class="fc-close fc-icon fc-icon-x" />
</span>
</div>
<div class="o_cw_body">
<t t-call="{{ constructor.subTemplates.body }}" />
<div class="card-footer border-top" t-att-class="{ 'o_footer_shrink': !hasFooter }">
<div class="card-footer border-top d-flex gap-1" t-att-class="{ 'o_footer_shrink': !hasFooter }">
<t t-call="{{ constructor.subTemplates.footer }}" />
</div>
</div>
@@ -38,15 +36,16 @@
<t t-if="isWorkLocationEvent()">
<ul class="list-group list-group-flush">
<div class="list-group-item">
<i class="fa fa-fw fa-calendar me-2" t-att-class="`wl_color_${props.record.colorIndex}`"/>
<t t-if="props.record.start.ts != props.record.end.ts">
<t t-esc="props.record.start.toFormat('d MMM')" t-options="{'widget': 'date'}"/>
<t t-esc="props.record.start.toFormat('d MMMM')" t-options="{'widget': 'date'}"/>
</t>
<t t-else="">
<t t-esc="props.record.start.toFormat('d MMM')" t-options="{'widget': 'date'}"/>
<t t-esc="props.record.start.toFormat('d MMMM yyyy')" t-options="{'widget': 'date'}"/>
</t>
</div>
<div class="list-group-item">
<i class="fa fa-fw fa-calendar" t-att-class="`wl_color_${props.record.colorIndex}`"/>
<i class="fa fa-fw fa-user me-2" t-att-class="`wl_color_${props.record.colorIndex}`"/>
<Field name="'employee_name'" record="slot.record"/>
</div>
</ul>
+12 -2
View File
@@ -3,12 +3,13 @@
from odoo import http
from odoo.http import request
from odoo.addons.calendar.controllers.main import CalendarController
class MicrosoftCalendarController(http.Controller):
class MicrosoftCalendarController(CalendarController):
@http.route('/microsoft_calendar/sync_data', type='json', auth='user')
def sync_data(self, model, **kw):
def microsoft_calendar_sync_data(self, model, **kw):
""" This route/function is called when we want to synchronize Odoo
calendar with Microsoft Calendar.
Function return a dictionary with the status : need_config_from_admin, need_auth,
@@ -55,3 +56,12 @@ class MicrosoftCalendarController(http.Controller):
}
return {"status": "success"}
@http.route()
def check_calendar_credentials(self):
res = super().check_calendar_credentials()
get_param = request.env['ir.config_parameter'].sudo().get_param
client_id = get_param('microsoft_calendar_client_id')
client_secret = get_param('microsoft_calendar_client_secret')
res['microsoft_calendar'] = bool(client_id and client_secret)
return res
@@ -1,11 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="microsoft_calendar.MicrosoftCalendarController" t-inherit="calendar.AttendeeCalendarController" t-inherit-mode="extension">
<xpath expr="//div[@id='calendar_sync_wrapper']" position="attributes">
<attribute name="t-if">true</attribute>
</xpath>
<xpath expr="//div[@id='microsoft_calendar_sync']" position="replace">
<div id="microsoft_calendar_sync" class="o_calendar_sync">
<FilterPanel position="after">
<div id="microsoft_calendar_sync" class="o_calendar_sync" t-if="model.microsoftCredentialsSet">
<button t-if="!model.microsoftIsSync and model.microsoftIsPaused" type="button" id="microsoft_sync_paused" class="o_google_sync_button o_microsoft_sync_paused btn btn-warning" t-on-click="onMicrosoftSyncUnpause">
<b>
<i id="microsoft_paused" class='fa fa-pause'/>
@@ -26,6 +23,6 @@
</b>
</button>
</div>
</xpath>
</FilterPanel>
</t>
</templates>
@@ -59,4 +59,8 @@ patch(AttendeeCalendarModel.prototype, {
this.microsoftPendingSync = false;
return result;
},
get microsoftCredentialsSet() {
return this.credentialStatus['microsoft_calendar'] ?? false;
}
});
@@ -136,31 +136,33 @@
<t t-name="web.DateTimePicker">
<div
class="o_datetime_picker d-flex flex-column gap-2 p-2 p-lg-3 user-select-none"
class="o_datetime_picker d-flex flex-column gap-1 user-select-none"
t-attf-style="--DateTimePicker__Day-template-columns: {{ props.showWeekNumbers ?? !props.range ? 8 : 7 }}"
>
<nav class="o_datetime_picker_header btn-group">
<button class="o_previous btn btn-light flex-grow-0" t-on-click="previous">
<i class="oi oi-chevron-left" t-att-title="activePrecisionLevel.prevTitle" />
</button>
<nav class="o_datetime_picker_header d-flex align-items-center justify-content-between">
<button
class="o_zoom_out o_datetime_button btn btn-light d-flex align-items-center justify-content-around px-0 text-truncate"
t-att-disabled="isLastPrecisionLevel"
class="o_zoom_out o_datetime_button btn btn-light btn-sm px-1 text-truncate"
t-att-class="isLastPrecisionLevel ? 'pe-none' : ''"
t-att-title="!isLastPrecisionLevel and activePrecisionLevel.mainTitle"
t-on-click="zoomOut"
>
<t t-foreach="titles" t-as="title" t-key="title">
<strong
t-attf-class="o_header_part fs-5 {{
t-attf-class="o_header_part fs-6 {{
props.range ? 'flex-basis-50' : 'flex-basis-100'
}}"
t-esc="title"
/>
</t>
</button>
<button class="o_next btn btn-light flex-grow-0" t-on-click="next">
<i class="oi oi-chevron-right" t-att-title="activePrecisionLevel.nextTitle" />
</button>
<div class="btn-group btn-group-sm">
<button class="o_previous btn btn-light btn-sm flex-grow-0 px-1" t-on-click="previous">
<i class="oi oi-chevron-left" t-att-title="activePrecisionLevel.prevTitle" />
</button>
<button class="o_next btn btn-light flex-grow-0 px-1" t-on-click="next">
<i class="oi oi-chevron-right" t-att-title="activePrecisionLevel.nextTitle" />
</button>
</div>
</nav>
<t t-if="state.precision === 'days'">
<t t-call="web.DateTimePicker.Days" />
+1 -1
View File
@@ -19,7 +19,7 @@
</header>
<!-- FIXME: WOWL there is a bug on t-portal on owl, in which t-portal don't work on multinode.
To avoid this we place the footer before the body -->
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-sm-start flex-wrap gap-1" style="order:2">
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-sm-start flex-wrap gap-1 w-100" style="order:2">
<t t-slot="footer" close="() => this.data.close()">
<button class="btn btn-primary o-default-button" t-on-click="() => this.data.close()">
<t>Ok</t>
@@ -42,6 +42,7 @@ export class PopoverController extends Component {
!this.popoverRef.el.contains(target)
) {
this.props.close();
ev.preventDefault();
}
}
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { evaluateExpr } from "@web/core/py_js/py";
import { visitXML } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
@@ -26,7 +27,8 @@ export class CalendarArchParser {
let jsClass = null;
let eventLimit = 5;
let scales = [...SCALES];
let scale = "week";
const sessionScale = browser.sessionStorage.getItem("calendar-scale");
let scale = sessionScale || "week";
let canCreate = true;
let canDelete = true;
let quickCreate = true;
@@ -2,8 +2,7 @@
$o-cw-popup-avatar-size: 16px;
.o_cw_popover {
min-width: 256px;
max-width: 328px;
width: 448px;
z-index: $zindex-modal - 1;
font-size: $font-size-base;
@@ -14,7 +13,8 @@ $o-cw-popup-avatar-size: 16px;
line-height: 1;
}
.card-footer {
.card-footer, .modal-footer {
--btn-group-gap: -1px;
background: none;
}
@@ -69,4 +69,33 @@ $o-cw-popup-avatar-size: 16px;
}
}
}
// =============== Generate color classes ===============
}
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
$color-subtle: mix($o-white, $color, 55%);
.modal.o_technical_modal.o_modal_full .modal-dialog .modal-content.o_calendar_color_#{$i - 1} {
.modal-header {
background-color: $color-subtle;
.btn {
color: $body-color;
}
}
}
.o_cw_popover.o_calendar_color_#{$i - 1} {
.card-header,
.card-header .popover-header {
background-color: lighten($color-subtle, 10%);
color: color-contrast($color-subtle);
}
.card-header {
padding-left: 2px;
border-color: mix($card-border-color, $color-subtle);
}
}
}
@@ -3,7 +3,7 @@
<t t-name="web.CalendarCommonPopover">
<t t-if="env.isSmall">
<Dialog title="props.record.title">
<Dialog title="props.record.title" contentClass="'o_calendar_color_'+ props.record.colorIndex">
<t t-call="{{ constructor.subTemplates.body }}" />
<t t-set-slot="footer">
<t t-call="{{ constructor.subTemplates.footer }}" />
@@ -17,16 +17,14 @@
<t t-name="web.CalendarCommonPopover.popover">
<div class="card-header d-flex justify-content-between py-2 pe-2">
<h4 class="p-0 pt-1">
<span class="popover-header border-0" t-esc="props.record.title" />
</h4>
<span class="popover-header border-0" t-esc="props.record.title" />
<span class="o_cw_popover_close ms-4 mt-2 me-2" t-on-click.stop="() => props.close()">
<i class="fa fa-close" />
<i class="fc-close fc-icon fc-icon-x" />
</span>
</div>
<div class="o_cw_body">
<t t-call="{{ constructor.subTemplates.body }}" />
<div class="card-footer border-top" t-att-class="{ 'o_footer_shrink': !hasFooter }">
<div class="card-footer d-flex gap-1 border-top" t-att-class="{ 'o_footer_shrink': !hasFooter }">
<t t-call="{{ constructor.subTemplates.footer }}" />
</div>
</div>
@@ -35,12 +33,12 @@
<t t-name="web.CalendarCommonPopover.body">
<ul class="list-group list-group-flush">
<li t-if="date" class="list-group-item">
<i class="fa fa-fw fa-calendar-o" />
<b class="text-capitalize" t-esc="date" /> <small t-if="dateDuration"><b t-esc="`(${dateDuration})`" /></small>
<i class="fa fa-fw fa-calendar text-400" />
<span class="fw-bold ms-2" t-esc="date" /> <small t-if="dateDuration != 'All day'"><b t-esc="dateDuration" /></small>
</li>
<li t-if="time" class="list-group-item">
<i class="fa fa-fw fa-clock-o" />
<b t-esc="time" /> <small t-if="timeDuration"><b t-esc="`(${timeDuration})`" /></small>
<i class="fa fa-fw fa-clock-o text-400" />
<span class="fw-bold ms-2" t-esc="time" /> <small t-if="timeDuration"><b t-esc="`(${timeDuration})`" /></small>
</li>
</ul>
<ul class="list-group list-group-flush o_cw_popover_fields_secondary">
@@ -49,16 +47,14 @@
<t t-set="fieldInfo" t-value="props.model.popoverFieldNodes[fieldId]"/>
<t t-if="!isInvisible(fieldInfo, slot.record)">
<li class="list-group-item flex-shrink-0 d-flex align-items-start" t-att-class="fieldInfo.attrs.class">
<strong class="me-2" t-if="!fieldInfo.options.noLabel">
<span class="fw-bold me-2" t-if="!fieldInfo.options.noLabel">
<t t-if="fieldInfo.options.icon">
<b>
<i t-att-class="fieldInfo.options.icon" />
</b>
<i t-attf-class="fa-fw {{fieldInfo.options.icon}} text-400" />
</t>
<t t-else="">
<t t-esc="fieldInfo.string" />:
<t t-esc="fieldInfo.string" />
</t>
</strong>
</span>
<div class="flex-grow-1">
<Field name="fieldInfo.name" record="slot.record" fieldInfo="fieldInfo" type="fieldInfo.widget" />
</div>
@@ -74,8 +70,14 @@
<a href="#" class="btn btn-primary o_cw_popover_edit" t-on-click="onEditEvent">Edit</a>
</t>
<t t-if="isEventDeletable">
<a href="#" class="btn btn-secondary o_cw_popover_delete ms-1" t-on-click="onDeleteEvent">Delete</a>
<a href="#" class="btn btn-secondary o_cw_popover_delete" t-on-click="onDeleteEvent">Delete</a>
</t>
</t>
<t t-name="web.PopoverOverlay" t-inherit="web.PopoverWowl" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_popover')]" position="before">
<div class="o_popoverOverlay"/>
</xpath>
</t>
</templates>
@@ -41,6 +41,8 @@ const HOUR_FORMATS = {
},
};
const { DateTime } = luxon;
export class CalendarCommonRenderer extends Component {
setup() {
this.fc = useFullCalendar("fullCalendar", this.options);
@@ -65,7 +67,7 @@ export class CalendarCommonRenderer extends Component {
get options() {
return {
allDaySlot: this.props.model.hasAllDaySlot,
allDayText: _t("All day"),
allDayText: _t(""),
columnHeaderFormat: this.env.isSmall
? SHORT_SCALE_TO_HEADER_FORMAT[this.props.model.scale]
: SCALE_TO_HEADER_FORMAT[this.props.model.scale],
@@ -81,6 +83,7 @@ export class CalendarCommonRenderer extends Component {
eventDrop: this.onEventDrop,
eventLimit: this.props.model.eventLimit,
eventLimitClick: this.onEventLimitClick,
eventLimitText: this.env.isSmall ? "" : "more",
eventMouseEnter: this.onEventMouseEnter,
eventMouseLeave: this.onEventMouseLeave,
eventRender: this.onEventRender,
@@ -111,6 +114,7 @@ export class CalendarCommonRenderer extends Component {
weekNumbers: true,
weekNumbersWithinDays: !this.env.isSmall,
windowResize: this.onWindowResizeDebounced,
columnHeaderHtml: this.getHeaderHtml,
};
}
@@ -173,7 +177,12 @@ export class CalendarCommonRenderer extends Component {
);
}
updateSize() {
const height = window.innerHeight - this.fc.el.getBoundingClientRect().top;
let headerHeight = 0;
if (!this.env.isSmall) {
headerHeight =
document.querySelector(".o_calendar_header")?.getBoundingClientRect()?.height ?? 0;
}
const height = window.innerHeight - this.fc.el.getBoundingClientRect().top - headerHeight;
this.fc.el.style.height = `${height}px`;
this.fc.api.updateSize();
}
@@ -203,11 +212,12 @@ export class CalendarCommonRenderer extends Component {
}
onEventClick(info) {
this.click(info);
info.el.classList.toggle("o_calendar_popover_open");
}
onEventRender(info) {
const { el, event } = info;
el.dataset.eventId = event.id;
el.classList.add("o_event", "py-0");
el.classList.add("o_event");
const record = this.props.model.records[event.id];
if (record) {
@@ -239,12 +249,21 @@ export class CalendarCommonRenderer extends Component {
if (record.duration <= 0.25) {
el.classList.add("o_event_oneliner");
}
}
if (DateTime.now() >= record.end) {
el.classList.add("o_past_event");
}
if (!el.querySelector(".fc-bg")) {
const bg = document.createElement("div");
bg.classList.add("fc-bg");
el.appendChild(bg);
if (!record.isAllDay && !record.isTimeHidden && record.isMonth) {
el.classList.add("o_event_dot");
} else if (record.isAllDay) {
el.classList.add("o_event_allday");
}
if (!el.classList.contains("fc-bg")) {
const bg = document.createElement("div");
bg.classList.add("fc-bg");
el.appendChild(bg);
}
}
}
async onSelect(info) {
@@ -305,9 +324,25 @@ export class CalendarCommonRenderer extends Component {
onWindowResize() {
this.updateSize();
}
getHeaderHtml(date) {
const scale = this.props.model.scale;
const {
weekdayShort: weekdayShort,
weekdayLong: weekdayLong,
day,
} = DateTime.fromJSDate(date);
return renderToString(this.constructor.headerTemplate, {
weekdayShort,
weekdayLong,
day,
scale,
});
}
}
CalendarCommonRenderer.components = {
Popover: CalendarCommonPopover,
};
CalendarCommonRenderer.template = "web.CalendarCommonRenderer";
CalendarCommonRenderer.eventTemplate = "web.CalendarCommonRenderer.event";
CalendarCommonRenderer.headerTemplate = "web.CalendarCommonRendererHeader";
@@ -6,13 +6,17 @@
</t>
<t t-name="web.CalendarCommonRenderer.event">
<div class="fc-content">
<div class="fc-content" t-att-class="isMonth ? 'd-flex gap-1 text-truncate' : ''">
<t t-if="!isTimeHidden">
<span class="fc-time" t-esc="startTime" />
<span class="d-none d-lg-inline fc-time" t-esc="startTime" />
<t> </t>
</t>
<div class="o_event_title" t-esc="title"/>
<div class="o_event_title text-truncate" t-out="title"/>
</div>
</t>
<t t-name="web.CalendarCommonRendererHeader">
<span class="o_cw_day_name" t-esc="scale == 'day' ? weekdayLong : weekdayShort "/> <span t-if="scale != 'month'" class="fc-day-number o_cw_day_number" t-esc="day"/>
</t>
</templates>
@@ -1,6 +1,9 @@
/** @odoo-module **/
import { deleteConfirmationMessage, ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import {
deleteConfirmationMessage,
ConfirmationDialog,
} from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
@@ -62,12 +65,15 @@ export class CalendarController extends Component {
getLocalState: () => this.model.exportedState,
});
const sessionShowSidebar = browser.sessionStorage.getItem("calendar.showSideBar");
this.state = useState({
isWeekendVisible:
browser.localStorage.getItem("calendar.isWeekendVisible") != null
? JSON.parse(browser.localStorage.getItem("calendar.isWeekendVisible"))
: true,
showSideBar: !this.env.isSmall,
showSideBar:
!this.env.isSmall &&
Boolean(sessionShowSidebar != null ? JSON.parse(sessionShowSidebar) : true),
});
this.searchBarToggler = useSearchBarToggler();
@@ -96,6 +102,44 @@ export class CalendarController extends Component {
}
}
get date() {
return this.model.meta.date || DateTime.now();
}
get today() {
return DateTime.now().toFormat("d");
}
get currentYear() {
return this.date.toFormat("y");
}
get dayHeader() {
return `${this.date.toFormat("d")} ${this.date.toFormat("MMMM")} ${this.date.year}`;
}
get weekHeader() {
const { rangeStart, rangeEnd } = this.model;
if (rangeStart.year != rangeEnd.year) {
return `${rangeStart.toFormat("MMMM")} ${rangeStart.year} - ${rangeEnd.toFormat(
"MMMM"
)} ${rangeEnd.year}`;
} else if (rangeStart.month != rangeEnd.month) {
return `${rangeStart.toFormat("MMMM")} - ${rangeEnd.toFormat("MMMM")} ${
rangeStart.year
}`;
}
return `${rangeStart.toFormat("MMMM")} ${rangeStart.year}`;
}
get currentMonth() {
return `${this.date.toFormat("MMMM")} ${this.date.year}`;
}
get currentWeek() {
return this.date.toFormat("W");
}
get rendererProps() {
return {
model: this.model,
@@ -153,10 +197,17 @@ export class CalendarController extends Component {
return {
model: this.model,
sideBarShown: this.state.showSideBar,
toggleSideBar: () => (this.state.showSideBar = !this.state.showSideBar),
toggleSideBar: () => {
this.state.showSideBar = !this.state.showSideBar;
},
};
}
toggleSideBar() {
this.state.showSideBar = !this.state.showSideBar;
browser.sessionStorage.setItem("calendar.showSideBar", this.state.showSideBar);
}
get showCalendar() {
return !this.env.isSmall || !this.state.showSideBar;
}
@@ -311,6 +362,7 @@ export class CalendarController extends Component {
async setScale(scale) {
await this.model.load({ scale });
browser.sessionStorage.setItem("calendar-scale", this.model.scale);
}
toggleWeekendVisibility() {
@@ -1,5 +1,5 @@
// Variables
$o-cw-color-today-accent: #fe3b30;
$o-cw-color-today-accent: #714B67;
$o-cw-filter-avatar-size: 20px;
// Animations
@@ -13,27 +13,38 @@ $o-cw-filter-avatar-size: 20px;
}
.o_calendar_container {
height: 100%;
display: flex;
grid-template-columns: auto minmax(auto, 210px);
@include media-breakpoint-up(xl){
grid-template: auto 1fr / 1fr auto;
grid-template-areas:
"head head"
"cal side";
}
.o_calendar_header {
grid-area: head;
}
}
.o_calendar_sidebar_container {
--Avatar-size: #{$o-cw-filter-avatar-size};
flex: 0 0 auto;
position: relative;
@include o-webclient-padding($top: $o-horizontal-padding/2);
grid-area: side;
background-color: $o-view-background-color;
border-right: 1px solid $border-color;
overflow-y: auto;
width: 240px;
@include media-breakpoint-up(xl){
width: 280px;
}
.o_calendar_sidebar {
@include o-webclient-padding($top: $o-horizontal-padding/2);
@include media-breakpoint-up(xl){
width: 210px;
}
.o_datetime_picker {
--DateTimePicker__Cell-size-md: 1.8rem;
@include media-breakpoint-up(xl){
--DateTimePicker__Cell-size-md: 2.2rem;
--DateTimePicker__Cell-size-md: 1.5rem;
}
padding-right: 0 !important;
padding-left: 0 !important;
@@ -42,6 +53,10 @@ $o-cw-filter-avatar-size: 20px;
--background-color: transparent;
border: none !important;
}
.o_cell_md {
font-size: .75rem;
}
}
// sync buttons are only displayed on calendar.event views
@@ -51,82 +66,17 @@ $o-cw-filter-avatar-size: 20px;
}
.o_calendar_filter {
font-size: 0.9em;
padding: 2em 0 1em;
.o_cw_filter_collapse_icon {
transition: all 0.3s ease;
@include o-hover-opacity();
font-size: 0.7em;
}
.collapsed .o_cw_filter_collapse_icon {
transform: rotate(90deg);
opacity: 1;
}
.o_calendar_filter_items_checkall,
.o_calendar_filter_item {
cursor: pointer;
overflow: hidden;
input {
z-index: -1;
opacity: 0;
}
.o_cw_filter_input_bg {
width: 1.3em;
height: 1.3em;
border-width: 2px;
border-style: solid;
border-radius: 1px;
overflow: hidden;
display: flex;
&.o_beside_avatar {
width: $o-cw-filter-avatar-size;
height: $o-cw-filter-avatar-size;
border-radius: 2px;
object-fit: cover;
align-items: center;
}
}
input:not(:checked) + label .o_cw_filter_input_bg {
&:not(.o_cw_filter_avatar) {
background: transparent !important;
}
i.fa {
visibility: hidden;
}
}
.o_cw_filter_avatar {
width: $o-cw-filter-avatar-size;
height: $o-cw-filter-avatar-size;
border-radius: 2px;
&.fa {
padding: 4px 3px;
}
}
.o_cw_filter_title {
line-height: $o-line-height-base;
flex-grow: 1;
}
button.o_remove {
@include o-position-absolute(0, 0, 0);
transform: translateX(100%);
transition: transform 0.2s;
transform: translate(100%, -50%);
}
&:hover {
button.o_remove {
transform: translateX(0%);
transform: translate(0%, -50%);
}
}
}
@@ -13,44 +13,57 @@
<t t-set-slot="layout-actions">
<SearchBar t-if="searchBarToggler.state.showSearchBar"/>
</t>
<t t-set-slot="control-panel-additional-breadcrums">
<span class="currentDate"><t t-out="currentDate"/></span>
</t>
<t t-set-slot="control-panel-navigation-additional">
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
</t>
<div class="o_calendar_container">
<MobileFilterPanel t-if="env.isSmall" t-props="mobileFilterPanelProps" />
<div t-if="showSideBar" class="o_calendar_sidebar_container d-md-block">
<div class="d-flex justify-content-between">
<div class="o_calendar_container d-flex d-lg-grid flex-column h-100 bg-view">
<div class="o_calendar_header d-flex align-items-center gap-1 p-2 border-bottom">
<div t-if="!env.isSmall" class="o_calendar_navigation_buttons btn-group">
<button
class="btn btn-secondary me-1 o_calendar_button_today"
t-on-click.stop="() => this.setDate('today')"
>
Today
</button>
<div class="o_calendar_navigation_buttons btn-group">
<button
class="o_calendar_button_prev btn btn-secondary btn-sm d-none d-md-block"
title="Previous"
aria-label="Previous"
t-on-click.stop="() => this.setDate('previous')"
><i class="oi oi-arrow-left"/></button>
<ViewScaleSelector scales="scales" currentScale="model.scale" isWeekendVisible="state.isWeekendVisible" setScale.bind="setScale" toggleWeekendVisibility.bind="toggleWeekendVisibility" />
<button
class="o_calendar_button_next btn btn-secondary btn-sm d-none d-md-block"
title="Next"
aria-label="Next"
t-on-click.stop="() => this.setDate('next')"
><i class="oi oi-arrow-right"/></button>
</div>
class="o_calendar_button_prev btn btn-secondary d-none d-md-block"
title="Previous"
aria-label="Previous"
t-on-click.stop="() => this.setDate('previous')"
><i class="oi oi-arrow-left"/></button>
<button
class="o_calendar_button_next btn btn-secondary d-none d-md-block"
title="Next"
aria-label="Next"
t-on-click.stop="() => this.setDate('next')"
><i class="oi oi-arrow-right"/></button>
</div>
<ViewScaleSelector scales="scales" currentScale="model.scale" isWeekendVisible="weekends" setScale.bind="setScale"/>
<button
class="btn btn-secondary o_calendar_button_today order-2 order-lg-0 ms-auto ms-lg-0"
t-att-class="env.isSmall ? 'btn-sm btn-light' : 'btn-secondary'"
t-on-click.stop="() => this.setDate('today')"
> <span t-if="env.isSmall" class="position-relative pt-1"><t t-esc="today"/><i class="fa fa-calendar-o position-absolute top-50 start-50 translate-middle fs-1"></i></span>
<t t-else="">Today</t>
</button>
<h5 class="d-inline-flex mb-0">
<t t-if="model.meta.scale === 'year'">
<t t-esc="currentYear"/>
</t>
<t t-elif="model.meta.scale === 'month'">
<t t-esc="currentMonth"/>
</t>
<t t-elif="model.meta.scale === 'week'">
<t t-esc="weekHeader"/> <span t-if="model.meta.scale === 'week'" class="badge bg-100 rounded px-1 ms-1">Week <t t-esc="currentWeek"/></span>
</t>
<t t-else="">
<t t-esc="dayHeader" />
</t>
</h5>
<button class="btn btn-light d-none d-lg-block oi oi-panel-right ms-auto collapsed" t-on-click="toggleSideBar"/>
</div>
<MobileFilterPanel t-if="env.isSmall" t-props="mobileFilterPanelProps" />
<t t-if="showCalendar" t-component="props.Renderer" t-props="rendererProps" />
<div t-if="showSideBar" class="o_calendar_sidebar_container col-auto overflow-x-hidden overflow-y-auto">
<div class="o_calendar_sidebar">
<DatePicker t-if="!env.isSmall" t-props="datePickerProps" />
<FilterPanel t-props="filterPanelProps" />
</div>
</div>
<t t-if="showCalendar" t-component="props.Renderer" t-props="rendererProps" />
</div>
</Layout>
</div>
@@ -1,17 +1,14 @@
@include media-breakpoint-down(md) {
.o_control_panel {
.o_calendar_header {
.o_calendar_button_today {
font-size: 0.7em;
line-height: 1.9em;
> .o_calendar_text {
margin-top: 3px;
> span {
font-size: .65rem
}
}
}
.o_calendar_container {
flex-direction: column;
.o_other_calendar_panel {
padding: 8px 16px;
@@ -38,7 +35,7 @@
}
.o_calendar_sidebar_container {
flex-grow: 1;
height: 100%;
> .o_calendar_sidebar {
width: auto;
@@ -549,6 +549,8 @@ export class CalendarModel extends Model {
isHatched: rawRecord["is_hatched"] || false,
isStriked: rawRecord["is_striked"] || false,
isTimeHidden: isTimeHidden || !showTime,
isMonth: this.meta.scale === "month",
isSmall: this.env.isSmall,
rawRecord,
};
}
@@ -843,4 +845,4 @@ export class CalendarModel extends Model {
};
}
}
CalendarModel.services = ["user"];
CalendarModel.services = ["user", "rpc"];
@@ -1,94 +1,100 @@
.o_calendar_renderer {
--o-circle-padding: calc(#{map-get($spacers, 1 )} / 2);
flex: 1 1 auto;
min-width: 0;
height: 100%;
background-color: map-get($grays, "100");
background: linear-gradient(-45deg, map-get($grays, "100"), $o-view-background-color);
--o-cw-border-color: #{lighten($border-color, 5%)};
background-color: $o-view-background-color;
grid-area: cal;
@include media-breakpoint-down(lg) {
border-top: 1px solid var(--o-cw-border-color);
}
.fc-event {
margin: 0 1px;
border-style: solid;
border-width: 0 0 0 3px;
box-sizing: border-box;
// `web/lib/fullcalendar/core/main.css` gives events with links and draggable events a hand mouse pointer,
// but doesn't take into account that events have popovers.
cursor: pointer;
color: $body-color;
background-color: $o-view-background-color;
transition: right .3s, left .3s;
overflow: hidden;
background: none;
font-size: 11px;
line-height: 1;
&:not([href]):not([tabindex]) {
color: $body-color;
@include media-breakpoint-down (md) {
line-height: 1.2;
}
&.fc-dragging.fc-day-grid-event.dayGridMonth .fc-content {
@include text-truncate();
margin: 4px 4px 3px;
&:hover {
color: $body-color;
&:not(.o_event_dot) .fc-bg {
--o-bg-opacity: .35;
}
}
&.fc-mirror, &.fc-dragging {
--o-bg-opacity: .25;
border-color: rgba($o-action, .5);
.fc-bg {
background-color: rgba($o-action, var(--o-bg-opacity));
}
}
&.fc-mirror {
background-color: rgba($o-action, var(--o-bg-opacity));
}
&.o_past_event::after{
content: '';
@include o-position-absolute(0, 0, 0, 0);
display: block;
z-index: 3;
background-color: rgba($o-view-background-color, var(--o-overlay-opacity));
}
.fc-bg {
background-color: mix(
map-get($theme-colors, "primary"),
white
); // Used for placeholder events only (on creation)
width: 101%;
height: 101%; // Compensate border
opacity: 0.7;
transition: opacity 0.2s;
transition: $transition-base;
}
.fc-content {
white-space: normal;
margin: 0.25rem;
font-size: 1.1em;
font-weight: 500;
}
> .o_event_title {
@include media-breakpoint-down(lg){
text-overflow: unset;
white-space: break-spaces;
// Try to show one full lien for short event
&.fc-short .fc-content {
margin-top: 1px;
}
&.o_cw_custom_highlight {
z-index: 10 !important;
.fc-bg {
opacity: 0.95;
> span {
word-break: break-word;
}
}
}
}
}
.o_calendar_widget {
height: 100%;
> .fc-view-container {
height: 100%;
}
// === Adapt calendar table borders ===
// =====================================
.fc-row .fc-widget-header {
padding-top: map-get($spacers, 2);
}
td {
border-color: $border-color;
// === Adapt calendar table borders and general layout ===
// ========================================================
th, td {
border-color: var(--o-cw-border-color);
}
.fc-time-grid .fc-slats .fc-minor td {
border-top-color: map-get($grays, "400");
border-top-color: var(--o-cw-border-color);
}
.fc-widget-content {
border-left-color: transparent;
.fc-body > tr > .fc-widget-content {
border-top: 0;
}
.fc-widget-header {
border-color: transparent;
border-bottom-color: $border-color;
padding: 3px 0 5px;
// The left padding must be equal to that of '.fc-axis.fc-time'
// This realigns the headers with their cells
.fc-week-number {
padding-left: 4px;
}
.fc-widget-content, .fc-widget-header {
border-bottom: 0;
}
hr.fc-widget-header {
@@ -97,12 +103,24 @@
background: map-get($grays, "400");
}
.fc-timeGrid-view .fc-day-grid .fc-row .fc-content-skeleton {
padding: 0.5em;
}
.fc-head-container {
position: sticky;
top: 0;
z-index: $zindex-modal;
border-top: 0;
background-color: $o-view-background-color;
.fc-event-container {
color: white;
.fc-week-number {
text-align: center;
font-size: $o-font-size-base-smaller;
> span {
display: none;
padding: map-get($spacers,1);
border-radius: $border-radius;
background-color: rgba($gray-200, .5);
}
}
}
.fc-more-popover {
@@ -120,44 +138,112 @@
}
.o_calendar_disabled {
background-color: $gray-200;
background-color: rgba($gray-200, .5);
}
// ====== Specific agenda types ======
// ====================================
// ====== Day only
.fc-timeGridDay-view {
.fc-day-header {
text-align: left;
border: 0;
> span {
gap: map-get($spacers, 1);
margin-bottom: map-get($spacers, 1);
}
.o_cw_day_number {
padding: map-get($spacers, 1);
}
}
}
// ====== Week only
.fc-timeGridWeek-view {
.fc-day-header {
text-align: center;
> span{
flex-direction: column;
}
.o_cw_day_number {
padding: map-get($spacers, 1) map-get($spacers, 2);
@include media-breakpoint-up(lg) {
font-size: 1.25rem;
}
}
}
.fc-time-grid {
.fc-event-container {
margin: 1px;
}
}
}
// ====== Both Day and Week agenda
.fc-timeGridDay-view,
.fc-timeGridWeek-view {
.fc-axis {
padding-right: $o-horizontal-padding;
.fc-head-container {
border-bottom: 1px solid var(--o-cw-border-color);
}
.fc-day-header {
> span {
display: flex;
align-items: center;
margin-left: map-get($spacers, 1);
}
&.fc-today {
color: $primary;
.o_cw_day_number {
position:relative;
color: $o-view-background-color;
&:before {
content: '';
@include o-position-absolute($top: 50%, $left: 50%);
transform: translate(-50%,-50%);
z-index: -1;
width: 100%;
border-radius: $border-radius-pill;
background: $primary;
aspect-ratio: 1;
color: $o-view-background-color;
}
}
}
}
.fc-widget-header.fc-today {
border-radius: 25px;
background: $o-brand-odoo;
color: white;
}
// Reinfornce default border color
tbody td {
border-top-color: map-get($grays, "400");
}
// Remove dotted borders (half-hours)
.fc-time-grid .fc-slats .fc-minor td {
border-top-style: none;
}
// Align labels and timelines
.fc-axis.fc-time {
border-top-color: transparent;
// Remove dotted borders (half-hours)
span {
max-width: 45px;
margin-top: -19px;
position: relative;
display: block;
// Align labels and timelines
.fc-axis{
font-size: .6rem;
@include media-breakpoint-up(md) {
font-size: .75rem;
}
&.fc-time {
border-top-color: transparent;
span {
max-width: 7ch;
margin-top: -22px;
position: relative;
display: block;
}
}
}
@@ -170,55 +256,60 @@
// Row containing "all day" events
div.fc-day-grid {
background-color: $o-view-background-color;
box-shadow: 0 6px 12px -6px rgba(black, 0.16);
border-left-color: rgba($o-brand-odoo, 0.3);
border-right-color: rgba($o-brand-odoo, 0.3);
box-shadow: 0 6px 12px -6px rgba(0, 0, 0, 0.16);
+ hr.fc-widget-header {
padding: 1px 0 0;
background: map-get($grays, "300");
}
.fc-day-grid-event {
@include media-breakpoint-down(md){
margin: 1px;
.fc-content {
max-height: 1.2em;
}
}
}
.fc-content-skeleton tr:not(:first-child) .fc-h-event {
margin-top: 3px;
}
}
// Create a 'preudo-border' for the first row. The actual border
// it's hidden because of border-collapse settings.
// Create a 'pseudo-border' for the first row. The actual border
// is hidden because of border-collapse settings.
.fc-slats tr:first-child td.fc-widget-content:last-child {
box-shadow: inset 0 1px 0 map-get($grays, "400");
box-shadow: inset 0 1px 0 var(--o-cw-border-color);
}
.fc-day.fc-widget-content.fc-today:not(.o_calendar_disabled) {
border-left-color: rgba($o-brand-odoo, 0.3);
border-right-color: rgba($o-brand-odoo, 0.3);
background: $o-view-background-color;
}
.fc-event {
--o-bg-opacity: .25;
// Prevent events with similar color to visually overlap each other
box-shadow: 0 0 0 1px white;
&.fc-event:not(.fc-h-event):not(.o_event_oneliner) {
border-width: 3px 0 0;
&.fc-not-start {
border-width: 0 0 3px;
&.fc-not-end {
border-width: 0;
}
}
&:not(.o_event_allday):not(.o_homework_event) {
box-shadow: 0 0 0 1px $o-view-background-color;
}
&.o_event_oneliner {
padding-top: 0;
padding-bottom: 0;
.fc-content {
&.o_cw_custom_highlight:not(.fc-dragging), &.o_calendar_popover_open:not(.fc-dragging) {
z-index: $zindex-modal !important;
right: 0px !important;
left: 0px !important;
margin-right: 0 !important;
}
.fc-content {
margin: map-get($spacers, 1);
@include media-breakpoint-up(md) {
margin-top: 0;
font-size: 11px !important;
line-height: 0.9;
margin-bottom: 0;
}
}
}
@@ -229,190 +320,124 @@
}
}
// ====== Both Month and Year agenda
// ====== Both Week and Month agenda
.fc-dayGridYear-view, .fc-dayGridMonth-view {
.fc-timeGridWeek-view, .fc-dayGridMonth-view {
.fc-day-header > span{
text-transform: uppercase;
font-size: $o-font-size-base-smaller;
}
}
// ====== Both Month and Months in Year agenda
.fc-dayGridMonth-view {
.fc-content-skeleton {
--o-cw-circle-padding: calc(#{map-get($spacers, 1 )} / 2);
--o-cw-color: var(--body-color);
// Realigns the background color and the number
height: 100%;
padding-bottom: 0;
.fc-day-number {
position: relative;
padding: var(--o-circle-padding);
color: var(--o-cw-color);
.fc-week-number {
line-height: $line-height-sm;
font-size: $o-font-size-base-smaller;
opacity: .8;
&:before {
content:"";
z-index: -1;
@include o_position-absolute($top: calc(#{var(--o-circle-padding)} * -1), $left: 50%);
transform: translateX(-50%);
display: none;
aspect-ratio: 1/1;
height: 100%;
border-radius: 100%;
background: var(--o-cw-bg, none);
padding: var(--o-circle-padding);
@include media-breakpoint-down(lg) {
> span {
font-size: .5rem;
}
}
@include media-breakpoint-up(lg) {
padding: map-get($spacers, 1);
}
}
.fc-today {
--o-cw-color: #{$white};
--o-cw-bg: #{$o-cw-color-today-accent};
.fc-day-top {
text-align: center;
.fc-day-number:before {
display: block;
&.fc-other-month {
opacity: 0.8;
&:not(.fc-today) .fc-day-number {
color: map-get($grays, "500");
}
}
.fc-day-number {
position: relative;
float: none !important;
color: var(--o-cw-color);
&:before {
content:"";
z-index: -1;
@include o_position-absolute($top: calc(#{var(--o-circle-padding)} * -1), $left: 50%);
transform: translateX(-50%);
display: none;
aspect-ratio: 1/1;
height: 100%;
border-radius: 100%;
background: var(--o-cw-bg, none);
padding: var(--o-circle-padding);
}
}
.fc-week-number {
// only in Month view
@include o-position-absolute($top: 0, $left: 0);
@include media-breakpoint-down(lg) {
background-color: $gray-100;
}
}
&.fc-today {
--o-cw-color: #{$white};
--o-cw-bg: #{$o-cw-color-today-accent};
padding-top: map-get($spacers, 1)/2;
.fc-day-number:before {
display: block;
}
}
}
}
}
// ====== Day only
.fc-timeGridDay-view .fc-event {
padding: 10px;
font-size: 14px;
// Try to avoid showing no title for short event
&.fc-short {
padding-top: 0;
padding-bottom: 0;
td:first-child {
border-left: none;
}
.fc-content {
margin-top: 1px !important;
}
}
// ====== Week only
.fc-timeGridWeek-view {
// Expand tiny events on hover/select
.fc-event:not(.fc-h-event).o_cw_custom_highlight {
transition: margin 0.1s 0.3s, left 0.1s 0.3s, right 0.1s 0.3s;
margin: 0 !important;
right: 1px !important;
left: 1px !important;
}
}
// ====== Month only
.fc-dayGridMonth-view {
margin-right: $o-horizontal-padding;
border-right: 1px solid $border-color;
.fc-event {
border-radius: 25px;
}
.fc-widget-header {
padding: 3px 0;
}
.fc-week-number {
background: none;
padding: 0.1rem 0.3rem 0.1rem 0 !important;
line-height: 1;
}
.fc-day-top {
text-align: center;
padding-top: 3px;
padding-bottom: 3px;
.fc-day-number {
float: none !important;
}
}
.fc-day-number {
margin: 5px;
padding: 0.1rem 0.3rem 0.1rem 0;
color: map-get($grays, "900");
font-weight: 400;
line-height: 1;
float: none !important;
}
.fc-day-top.fc-other-month {
opacity: 0.8;
.fc-day-number {
color: map-get($grays, "500");
}
}
td:last-child {
border-right-color: transparent;
}
.fc-bg .fc-today {
border-color: map-get($grays, "300");
}
.fc-bg .fc-today:not(.o_calendar_disabled) {
background: $o-view-background-color;
background: none;
}
.fc-more-cell {
> div,
.fc-more {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
}
.fc-event {
margin: 0 3px 2px;
@include media-breakpoint-up(lg) {
margin: 0 3px 2px;
}
.fc-content {
display: flex;
justify-content: start;
flex-direction: row;
margin: 4px 4px 3px;
}
.fc-time:not(:empty) {
/*rtl:ignore*/
padding-right: 0.5em;
}
.o_event_title {
@include text-truncate();
}
&.fc-not-start {
border-right-width: 3px;
.fc-content {
padding-left: 6px;
}
}
&.fc-not-end {
margin-right: 0;
.fc-content {
padding-right: 6px;
}
max-height: 1.2em;
}
}
}
// ====== Year only
.fc-dayGridYear-view {
border: none;
height: 100%;
padding-right: $o-horizontal-padding;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
overflow-y: auto;
@include media-breakpoint-down(md) {
padding-right: 5px;
padding-left: 5px;
}
> table {
height: 100%;
}
@@ -431,66 +456,76 @@
}
&:not(.fc-readonly-year-view) {
.fc-day-top:hover > .fc-day-number {
--o-cw-color: #{map-get($grays, "900")};
--o-cw-bg: #{rgba(#87c0d1, 0.5)};
.fc-day-top {
&.fc-today {
padding-top: 0;
}
position: relative;
font-weight: bold;
text-align: center;
&:hover > .fc-day-number {
--o-cw-color: #{$o-white};
--o-cw-bg: #{mix($o-white, $o-action, 50%)};
&:before {
display: block;
&:before {
display: block;
}
}
}
}
> .fc-month-container {
width: 25%;
min-width: 25rem;
box-sizing: border-box;
@include media-breakpoint-down(md) {
width: 50%;
width: 100%;
}
> .fc-month {
width: 21rem;
margin: auto;
@include media-breakpoint-down(md) {
width: 95%;
}
> .fc-toolbar.fc-header-toolbar {
padding-top: 10px;
margin-bottom: 4px;
cursor: default;
justify-content: start;
h2 {
font-size: 1.2rem;
color: map-get($grays, "600");
font-size: $font-size-base;
}
}
.fc-widget-header {
padding: 2px 0;
position: static;
cursor: default;
}
.fc-dayGridMonth-view {
margin-right: none;
border-right: none;
.fc-has-event {
background-color: #b4dff5;
}
.fc-week-number {
cursor: default;
opacity: .5;
}
.fc-day-header {
font-size: $o-font-size-base-smaller;
}
}
.fc-week.fc-row {
display: flex;
align-items: center;
min-height: 2rem;
.fc-week-number {
padding: 0;
> span {
padding: 0;
vertical-align: middle;
}
}
}
.fc-disabled-day {
@@ -499,21 +534,7 @@
.fc-day-top {
text-align: center;
padding: 0.4vh;
cursor: pointer;
> .fc-day-number {
display: block;
float: unset;
line-height: unset;
margin: auto;
padding: 0.1rem 0;
font-size: calc(11px + 0.2vh);
}
&.fc-today > .fc-day-number {
font-weight: bold;
}
}
}
}
@@ -550,107 +571,39 @@
}
}
.o_calendar_renderer .fc-view {
.fc-bgevent {
opacity: 0.5;
}
.fc-event {
opacity: 1;
&.o_event_highlight {
opacity: 1;
.fc-content {
font-weight: bold;
}
}
&.o_cw_custom_highlight {
z-index: 10 !important;
opacity: 1;
right: 1px !important;
left: 1px !important;
margin-right: 0 !important;
}
}
}
.o_cw_popover {
.card-header {
padding-left: 2px;
}
}
// =============== Generate color classes ===============
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
$color-subtle: mix($o-white, $color, 55%);
.o_calendar_renderer .fc-view {
.o_calendar_color_#{$i - 1} {
border-color: $color;
&.fc-bgevent {
background-color: $color;
&.fc-bgevent, &:not(.o_event_dot) .fc-bg {
background-color: $color-subtle;
}
&.fc-event {
color: darken($color, 45%);
background: scale-color($color, $lightness: 50%);
.fc-bg {
background: scale-color($color, $lightness: 50%);
&.fc-event:not(.o_event_dot):not(.fc-dragging) {
color: color-contrast($color-subtle);
border-color: tint-color($color, 50%);
&.o_cw_custom_highlight, &.o_calendar_popover_open {
box-shadow: 0px 8px 2px -7px rgba(0, 0, 0, 0.5);
z-index: 2; // for the shadow to go over the events below
}
&.o_cw_custom_highlight {
box-shadow: 0 12px 12px -5px rgba($color, 0.3);
color: color-contrast($color);
}
}
&.o_event_hatched {
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba($color, 0.4) 10px,
rgba($color, 0.4) 20px
) !important;
.fc-bg {
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba($color, 0.5) 10px,
rgba($color, 0.5) 20px
);
}
}
&.o_event_striked {
background: linear-gradient(
transparent 0 45%,
$color 45% 55%,
transparent 55% 100%);
}
}
}
.o_cw_filter_color_#{$i - 1} {
.o_cw_filter_input_bg:not(.no_filter_color) {
&.form-check:hover .o_cw_filter_input_bg:not(.no_filter_color) {
border-color: shade-color($color, 20%);
}
.o_cw_filter_input_bg {
border-color: $color;
background: $color;
color: color-contrast($color);
}
}
.o_cw_popover.o_calendar_color_#{$i - 1} {
$color-subdle: mix($o-white, $color, 85%);
.card-header,
.card-header .popover-header {
background-color: $color-subdle;
color: color-contrast($color-subdle);
}
.card-header {
border-color: mix($card-border-color, mix($o-white, $color));
&:checked {
background-color: $color;
}
}
}
}
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.CalendarRenderer">
<div class="o_calendar_renderer">
<div class="o_calendar_renderer h-100">
<ActionSwiper t-props="actionSwiperProps">
<t
t-component="calendarComponent"
@@ -6,23 +6,19 @@
.fc-axis {
padding-left: 0;
}
.fc-week-number.fc-widget-header {
font-weight: normal;
white-space: normal;
}
}
&:not(.fc-timeGridDay-view) {
.fc-day-header, .fc-week-number.fc-widget-header {
word-spacing: 250px; // force line break in week mode
text-align: center;
}
&.fc-timeGrid-view .fc-axis.fc-time span {
max-width: 5ch;
}
&.fc-dayGridMonth-view {
padding-left: 0;
.fc-week-number:not(.fc-widget-header) {
background-color: $gray-400;
color: $gray-800;
}
.fc-body > tr > .fc-widget-content {
border: 0
}
.fc-head-container.fc-widget-header {
border-left: 0;
border-right: 0;
}
}
@@ -1,14 +1,34 @@
.o_cw_popover_link {
border: solid 2px;
padding-left: 5px;
margin-top: 2px;
color: black;
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
$color-subtle: mix($o-white, $color, 55%);
&.o_calendar_color_#{$i - 1} {
--o-bg-opacity: 1;
border-color: $color;
background: $color;
background: rgba($color-subtle, var(--o-bg-opacity));
color: color-contrast($color-subtle);
font-weight: $font-weight-normal;
border-radius: $border-radius;
padding: 0 map-get($spacers, 1);
&:hover {
--o-bg-opacity: .5 !important;
}
&.o_event_dot {
--o-bg-opacity: 0;
&:before {
content: '\f111';
margin-right: map-get($spacers, 1);
font-family: FontAwesome;
font-size: $o-font-size-base-smaller;
color: $color;
}
}
}
}
}
@@ -16,26 +16,26 @@
</t>
<t t-name="web.CalendarYearPopover.popover">
<div class="position-absolute" style="top: 0; right: 0.5rem;">
<div class="position-absolute" style="top: .75rem; right: 1rem;">
<span class="o_cw_popover_close" t-on-click.stop="() => props.close()">
<i class="fa fa-close small" />
<i class="fc-close fc-icon fc-icon-x" />
</span>
</div>
<div class="o_cw_body popover-body">
<div class="o_cw_body popover-body d-flex flex-column gap-2">
<t t-call="{{ constructor.subTemplates.body }}" />
<div t-if="props.model.canCreate" class="mt-2">
<t t-if="props.model.canCreate">
<t t-call="{{ constructor.subTemplates.footer }}" />
</div>
</t>
</div>
</t>
<t t-name="web.CalendarYearPopover.body">
<t t-foreach="recordGroups" t-as="recordGroup" t-key="recordGroup.title">
<div class="fw-bold mt-2" t-esc="recordGroup.title" />
<div t-foreach="recordGroups" t-as="recordGroup" t-key="recordGroup.title">
<h5 t-out="recordGroup.title" />
<t t-foreach="recordGroup.records" t-as="record" t-key="record.id">
<t t-call="{{ constructor.subTemplates.record }}" />
</t>
</t>
</div>
</t>
<t t-name="web.CalendarYearPopover.footer">
@@ -57,12 +57,12 @@
href="#"
t-on-click.prevent="() => this.onRecordClick(record)"
t-attf-style="{{ getRecordStyle(record) }}"
t-attf-class="o_cw_popover_link btn-link d-block {{ getRecordClass(record) }}"
t-attf-class="o_cw_popover_link d-flex align-items-center gap-1 {{ getRecordClass(record) }} {{record.startHour ? 'o_event_dot' : 'o_event_allday'}}"
t-att-data-id="record.id"
t-att-data-title="record.title"
>
<t t-if="record.startHour"><t t-esc="record.startHour" /> </t>
<t t-esc="record.title"/>
<span t-if="record.startHour"><t t-out="record.startHour" /></span>
<span t-esc="record.title"/>
</a>
</t>
@@ -56,7 +56,7 @@ export class CalendarYearRenderer extends Component {
selectable: this.props.model.canCreate,
showNonCurrentDates: false,
timeZone: luxon.Settings.defaultZone.name,
titleFormat: { month: "short", year: "numeric" },
titleFormat: { month: "long", year: "numeric" },
unselectAuto: false,
weekNumberCalculation: "ISO",
weekNumbers: false,
@@ -2,9 +2,9 @@
<templates xml:space="preserve">
<t t-name="web.CalendarYearRenderer">
<div class="o_calendar_widget" t-ref="root">
<div class="o_calendar_widget overflow-y-auto" t-ref="root">
<div class="fc-view-container">
<div class="fc fc-dayGridYear-view">
<div class="fc fc-dayGridYear-view d-flex flex-wrap justify-content-evenly gap-3 px-2 py-3">
<t t-foreach="months" t-as="month" t-key="month">
<div class="fc-month-container">
<div class="fc-month" t-ref="fullCalendar-{{ month }}" />
@@ -7,7 +7,16 @@
overflow: hidden !important;
}
&.o-section-slide-enter-active {
max-height: 20rem; // fixed value is required to properly trigger transition
max-height: 100%; // max-height is required to properly trigger transition
overflow: auto;
}
}
.o_calendar_filter_item {
&.no_filter_color, &.o_cw_filter_color_false, &:not([class*='o_cw_filter_color']) {
input, input:hover {
border-color: $light;
background-color: transparent !important;
filter: invert(1) grayscale(1);
}
}
}
}
@@ -5,63 +5,52 @@
<t t-foreach="props.model.filterSections" t-as="section" t-key="section.fieldName">
<t t-if="section.filters.length gt 0">
<div
class="o_calendar_filter"
class="o_calendar_filter d-flex flex-column gap-1 mt-4"
t-att-class="{'o-calendar-filter-panel--section-collapsed': isSectionCollapsed(section)}"
t-att-data-name="section.fieldName"
>
<t t-if="section.label">
<div class="d-flex">
<div
class="o_calendar_filter_items_checkall me-2"
data-value="section"
>
<t t-set="filterId" t-value="nextFilterId" />
<input
type="checkbox"
name="select-all"
class="position-absolute"
t-attf-id="o_calendar_filter_{{filterId}}"
t-att-checked="isAllActive(section)"
t-on-change="(ev) => this.onAllFilterInputChange(section, ev)"
/>
<div
class="o_calendar_filter_items_checkall o-checkbox form-check"
data-value="section"
>
<t t-set="filterId" t-value="nextFilterId"/>
<input
type="checkbox"
name="select-all"
class="form-check-input"
t-attf-id="o_calendar_filter_{{filterId}}"
t-att-checked="isAllActive(section)"
t-on-change="(ev) => this.onAllFilterInputChange(section, ev)"
/>
<t t-if="section.canCollapse">
<label
class="d-flex align-items-center m-0"
t-attf-for="o_calendar_filter_{{filterId}}"
>
<span class="o_cw_filter_input_bg o_calendar_filter_all">
<i class="fa fa-check position-relative" />
</span>
</label>
</div>
<t t-if="section.canCollapse">
<div
class="justify-content-between align-items-center h5"
class="d-flex align-items-center"
type="button"
t-on-click.stop.prevent="() => this.toggleSection(section, ev)"
>
<span class="o_cw_filter_label" t-esc="section.label" />
<span class="o_cw_filter_label fw-bolder" t-esc="section.label"/>
<i
class="o_cw_filter_collapse_icon fa"
t-attf-class="fa-chevron-{{ isSectionCollapsed(section) ? 'left' : 'down' }}"
class="o_cw_filter_collapse_icon fa ms-1"
t-attf-class="fa-caret-{{ isSectionCollapsed(section) ? 'left' : 'down' }}"
/>
</div>
</label>
</t>
<t t-else="">
<h5 class="o_cw_filter_label" t-esc="section.label" />
<label class="o_cw_filter_label fw-bolder" t-esc="section.label"/>
</t>
</div>
</t>
<Transition visible="!isSectionCollapsed(section)" name="'o-section-slide'" leaveDuration="350" t-slot-scope="transition">
<div class="o_calendar_filter_items" t-att-class="transition.className">
<div class="o_calendar_filter_items d-flex flex-column gap-1" t-att-class="transition.className">
<t t-foreach="getSortedFilters(section)" t-as="filter" t-key="filter.value">
<t t-set="filterId" t-value="nextFilterId" />
<t t-call="{{ constructor.subTemplates.filter }}" />
<t t-set="filterId" t-value="nextFilterId"/>
<t t-call="{{ constructor.subTemplates.filter }}"/>
</t>
</div>
</Transition>
<t t-if="section.canAddFilter">
<AutoComplete t-props="getAutoCompleteProps(section)" />
<AutoComplete t-props="getAutoCompleteProps(section)"/>
</t>
</div>
</t>
@@ -70,56 +59,52 @@
<t t-name="web.CalendarFilterPanel.filter">
<div
class="o_calendar_filter_item w-100 position-relative mb-2"
class="o_calendar_filter_item o-checkbox form-check position-relative w-100 overflow-hidden cursor-pointer"
t-att-class="getFilterColor(filter)"
t-att-data-value="filter.value"
>
<input
type="checkbox"
name="selection"
class="position-absolute"
class="o_cw_filter_input_bg form-check-input"
t-att-style="filter.colorIndex and typeof filter.colorIndex !== 'number' ? `border-color: ${filter.colorIndex}; background-color: ${filter.colorIndex};` : ''"
t-attf-id="o_calendar_filter_item_{{filterId}}"
t-att-checked="filter.active"
t-on-change="(ev) => this.onFilterInputChange(section, filter, ev)"
/>
<label
class="d-flex align-items-start m-0"
class="d-flex align-items-center gap-1"
t-attf-for="o_calendar_filter_item_{{filterId}}"
>
<span
class="o_cw_filter_input_bg d-flex flex-shrink-0 justify-content-center position-relative me-1 o_beside_avatar"
>
<i class="fa fa-check position-relative" />
</span>
<t t-if="section.hasAvatar and filter.hasAvatar">
<img
class="o_cw_filter_avatar flex-shrink-0 me-1"
class="o_cw_filter_avatar o_avatar rounded"
t-attf-src="/web/image/{{ section.avatar.model }}/{{ filter.value }}/{{ section.avatar.field }}"
alt="Avatar"
/>
</t>
<t t-elif="filter.type === 'all'">
<i
class="o_cw_filter_avatar fa fa-users fa-fw flex-shrink-0 me-1"
class="o_cw_filter_avatar o_avatar fa fa-users fa-fw flex-shrink-0 me-1"
role="img"
aria-label="Avatar"
title="Avatar"
/>
</t>
<span
class="o_cw_filter_title text-truncate flex-grow"
class="o_cw_filter_title flex-grow-1 text-truncate lh-base"
t-esc="filter.label"
/>
</label>
<t t-if="filter.canRemove">
<button
class="o_remove btn bg-white text-700 py-0 px-2"
class="o_remove btn position-absolute top-50 end-0 bg-white py-0 px-2 text-700 transition-base"
role="img"
title="Remove this favorite from the list"
aria-label="Remove this favorite from the list"
t-on-click="() => this.onFilterRemoveBtnClick(section, filter)"
>
<i class="fa fa-times" />
<i class="oi oi-close"/>
</button>
</t>
</div>
@@ -3,22 +3,20 @@
<t t-name="web.CalendarMobileFilterPanel">
<div class="o_other_calendar_panel d-flex align-items-center" t-on-click="props.toggleSideBar">
<i class="fa fa-fw fa-filter me-3" />
<div class="o_filter me-auto d-flex overflow-auto">
<t t-foreach="props.model.filterSections" t-as="section" t-key="section.fieldName">
<t t-if="section.filters.length gt 0">
<span class="text-nowrap fw-bold text-uppercase me-1" t-esc="section.label" />
<t t-foreach="getSortedFilters(section)" t-as="filter" t-key="filter.value">
<span t-if="filter.active" class="d-flex align-items-center text-nowrap ms-1 me-2">
<span t-att-class="getFilterColor(filter)">⬤</span>
<i class="fa fa-fw" t-att-class="getFilterColor(filter) !== 'o_color_false' ? 'fa-circle ' + getFilterColor(filter) : 'fa-circle-o'"/>
<span class="ms-1 fw-bold text-nowrap" t-esc="filter.label" />
</span>
</t>
</t>
</t>
</div>
<i t-attf-class="fa fa-fw fa-caret-{{caretDirection}} ms-2" />
<i t-attf-class="oi fa-fw oi-chevron-{{caretDirection}} ms-2" />
</div>
</t>
@@ -58,7 +58,7 @@
</t>
<t t-name="web.FormView.Buttons">
<div class="o_cp_buttons d-flex align-items-baseline" role="toolbar" aria-label="Main actions" t-ref="cpButtons">
<div class="o_cp_buttons d-flex align-items-baseline w-100" role="toolbar" aria-label="Main actions" t-ref="cpButtons">
<div t-if="model.root.isInEdition" class="o_form_buttons_edit d-flex gap-1">
<button type="button" class="btn btn-primary o_form_button_save" data-hotkey="s" t-on-click.stop="() => this.saveButtonClicked({closable: true})">
Save
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.ViewScaleSelector">
<Dropdown t-if="Object.keys(props.scales).length > 1" class="'btn-group o_view_scale_selector'" togglerClass="'btn btn-secondary scale_button_selection'" hotkey="'v'" showCaret="true">
<Dropdown t-if="Object.keys(props.scales).length > 1" class="'btn-group o_view_scale_selector order-3 order-lg-0'" togglerClass="'btn btn-secondary scale_button_selection'" hotkey="'v'" showCaret="true">
<t t-set-slot="toggler">
<t t-esc="props.scales[props.currentScale].description" />
</t>
@@ -85,6 +85,30 @@ QUnit.test("close on click away", async (assert) => {
assert.containsNone(fixture, ".o_popover #comp");
});
QUnit.test("close on click away should be default prevented", async (assert) => {
assert.expect(6);
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
popovers.add(popoverTarget, Comp, {});
await nextTick();
assert.containsOnce(fixture, ".o_popover");
assert.containsOnce(fixture, ".o_popover #comp");
const closeButton = fixture.querySelector("#close");
function clickListener(ev) {
assert.step(`click-event-${ev.defaultPrevented ? "default-prevented" : ""}`);
}
closeButton.addEventListener("pointerdown", clickListener);
await click(fixture, "#close");
assert.containsNone(fixture, ".o_popover");
assert.containsNone(fixture, ".o_popover #comp");
assert.verifySteps(["click-event-default-prevented"]);
closeButton.removeEventListener("pointerdown", clickListener);
});
QUnit.test("close on 'Escape' keydown", async (assert) => {
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
@@ -95,7 +95,7 @@ QUnit.module("Views", ({ beforeEach }) => {
await click(target, ".o_calendar_container .o_other_calendar_panel");
assert.isVisible(
target.querySelector(
".o_calendar_container .o_calendar_sidebar_container button.o_calendar_button_today"
".o_calendar_container .o_calendar_header button.o_calendar_button_today"
),
"today button should be visible"
);
@@ -103,45 +103,41 @@ QUnit.module("Views", ({ beforeEach }) => {
// Test all views
// displays month mode by default
assert.equal(
target.querySelector(
".o_calendar_container .o_calendar_sidebar_container .dropdown-toggle"
).textContent,
target.querySelector(".o_calendar_container .o_calendar_header .dropdown-toggle")
.textContent,
"Week",
"should display the current week"
);
// switch to day mode
await click(target, ".o_calendar_sidebar_container .dropdown-toggle");
await click(target, ".o_calendar_sidebar_container .o_scale_button_day");
await click(target, ".o_calendar_container .o_calendar_header .dropdown-toggle");
await click(target, ".o_calendar_container .o_calendar_header .o_scale_button_day");
await nextTick();
assert.equal(
target.querySelector(
".o_calendar_container .o_calendar_sidebar_container .dropdown-toggle"
).textContent,
target.querySelector(".o_calendar_container .o_calendar_header .dropdown-toggle")
.textContent,
"Day",
"should display the current day"
);
// switch to month mode
await click(target, ".o_calendar_sidebar_container .dropdown-toggle");
await click(target, ".o_calendar_sidebar_container .o_scale_button_month");
await click(target, ".o_calendar_container .o_calendar_header .dropdown-toggle");
await click(target, ".o_calendar_container .o_calendar_header .o_scale_button_month");
await nextTick();
assert.equal(
target.querySelector(
".o_calendar_container .o_calendar_sidebar_container .dropdown-toggle"
).textContent,
target.querySelector(".o_calendar_container .o_calendar_header .dropdown-toggle")
.textContent,
"Month",
"should display the current month"
);
// switch to year mode
await click(target, ".o_calendar_sidebar_container .dropdown-toggle");
await click(target, ".o_calendar_sidebar_container .o_scale_button_year");
await click(target, ".o_calendar_container .o_calendar_header .dropdown-toggle");
await click(target, ".o_calendar_container .o_calendar_header .o_scale_button_year");
await nextTick();
assert.equal(
target.querySelector(
".o_calendar_container .o_calendar_sidebar_container .dropdown-toggle"
).textContent,
target.querySelector(".o_calendar_container .o_calendar_header .dropdown-toggle")
.textContent,
"Year",
"should display the current year"
);
@@ -200,18 +196,21 @@ QUnit.module("Views", ({ beforeEach }) => {
</calendar>`,
});
assert.containsOnce(target, ".o_calendar_renderer");
assert.containsOnce(target, ".o_other_calendar_panel");
await click(target, ".o_other_calendar_panel");
assert.containsOnce(
target,
".o_calendar_filter_items_checkall",
"should contain one filter to check all"
);
assert.containsN(
target,
".o_other_calendar_panel .o_filter > *",
3,
"should contains 3 child nodes -> 1 label (USER) + 2 resources (user 1/2)"
".o_calendar_filter_item",
2,
"should contain 2 child nodes -> 2 resources"
);
assert.containsNone(target, ".o_calendar_sidebar");
assert.containsOnce(target, ".o_calendar_renderer");
// Toggle the other calendar panel should hide the calendar view and show the sidebar
await click(target, ".o_other_calendar_panel");
assert.containsOnce(target, ".o_calendar_sidebar");
assert.containsNone(target, ".o_calendar_renderer");
assert.containsOnce(target, ".o_calendar_filter");
@@ -222,8 +221,8 @@ QUnit.module("Views", ({ beforeEach }) => {
assert.containsN(
target,
".o_other_calendar_panel .o_filter > *",
1,
"should contains 1 child node -> 1 label (USER)"
0,
"should contain 0 child nodes -> no filters selected"
);
// Toggle again the other calendar panel should hide the sidebar and show the calendar view
@@ -368,11 +367,12 @@ QUnit.module("Views", ({ beforeEach }) => {
assert.equal(target.querySelector(".fc-day-header[data-date]").dataset.date, "2016-02-05");
// Change scale to month
await click(target, ".o_calendar_container .o_other_calendar_panel");
await changeScale(target, "month");
assert.containsOnce(target, ".currentDate");
assert.strictEqual(document.querySelector(".currentDate").textContent, " - February 2016");
await click(target, ".o_calendar_container .o_other_calendar_panel");
assert.containsOnce(target, ".o_calendar_container .o_calendar_header h5");
assert.strictEqual(
document.querySelector(".o_calendar_container .o_calendar_header h5").textContent,
"February 2016"
);
assert.containsNone(target, ".fc-timeGridDay-view");
assert.containsOnce(target, ".fc-dayGridMonth-view");
@@ -380,7 +380,10 @@ QUnit.module("Views", ({ beforeEach }) => {
await tap(target, ".fc-day-top[data-date='2016-02-10']");
await nextTick(); // await reload & render
await nextTick(); // await breadcrumb update
assert.strictEqual(document.querySelector(".currentDate").textContent, "");
assert.strictEqual(
document.querySelector(".o_calendar_container .o_calendar_header h5").textContent,
"10 February 2016"
);
assert.containsNone(target, ".fc-dayGridMonth-view");
assert.containsOnce(target, ".fc-timeGridDay-view");
assert.equal(target.querySelector(".fc-day-header[data-date]").dataset.date, "2016-02-10");
@@ -62,7 +62,7 @@ QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "July 16, 2021 (All day)");
assert.strictEqual(dateTimeLabels, "July 16, 2021");
});
QUnit.test("date duration: is all day and two days duration", async (assert) => {
@@ -77,7 +77,7 @@ QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "July 16-17, 2021 (2 days)");
assert.strictEqual(dateTimeLabels, "July 16-17, 2021 2 days");
});
QUnit.test("time duration: 1 hour diff", async (assert) => {
@@ -152,10 +152,7 @@ QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(
dateTimeLabels,
"July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)"
);
assert.strictEqual(dateTimeLabels, "July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)");
});
QUnit.test("isTimeHidden is true", async (assert) => {
@@ -177,10 +174,7 @@ QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(
dateTimeLabels,
"July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)"
);
assert.strictEqual(dateTimeLabels, "July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)");
});
QUnit.test("canDelete is true", async (assert) => {
@@ -67,7 +67,9 @@ QUnit.module("CalendarView - CommonRenderer", ({ beforeEach }) => {
QUnit.test("Day: check date", async (assert) => {
await start({ model: { scale: "day" } });
assert.containsOnce(target, ".fc-day-header");
assert.strictEqual(target.querySelector(".fc-day-header").textContent, "July 16, 2021");
const dayHeader = target.querySelector(".fc-day-header");
assert.strictEqual(dayHeader.querySelector(".o_cw_day_name").textContent, "Friday");
assert.strictEqual(dayHeader.querySelector(".o_cw_day_number").textContent, "16");
});
QUnit.test("Day: click all day slot", async (assert) => {
@@ -152,11 +154,13 @@ QUnit.module("CalendarView - CommonRenderer", ({ beforeEach }) => {
await start({ model: { scale: "week" } });
assert.containsN(target, ".fc-day-header", 7);
const dates = ["Sun 11", "Mon 12", "Tue 13", "Wed 14", "Thu 15", "Fri 16", "Sat 17"];
const dateNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const dates = ["11", "12", "13", "14", "15", "16", "17"];
const els = target.querySelectorAll(".fc-day-header");
for (let i = 0; i < els.length; i++) {
assert.strictEqual(els[i].textContent, dates[i]);
assert.strictEqual(els[i].querySelector(".o_cw_day_name").textContent, dateNames[i]);
assert.strictEqual(els[i].querySelector(".o_cw_day_number").textContent, dates[i]);
}
});
@@ -928,7 +928,7 @@ QUnit.module("Views", ({ beforeEach }) => {
);
assert.strictEqual(
popoverDescription.children[2].textContent,
"(2 days)",
"2 days",
"The popover description should indicate 2 days"
);
await click(target, ".o_cw_popover_close");
@@ -1185,15 +1185,15 @@ QUnit.module("Views", ({ beforeEach }) => {
},
});
const dayHeaders = target.querySelectorAll(".fc-day-header");
const dayHeaders = target.querySelectorAll(".fc-day-header .o_cw_day_name");
assert.strictEqual(
dayHeaders[0].textContent,
"Sun 11",
"Sun",
"The first day of the week should be Sunday"
);
assert.strictEqual(
dayHeaders[dayHeaders.length - 1].textContent,
"Sat 17",
"Sat",
"The last day of the week should be Saturday"
);
});
@@ -1226,15 +1226,15 @@ QUnit.module("Views", ({ beforeEach }) => {
},
});
const dayHeaders = target.querySelectorAll(".fc-day-header");
const dayHeaders = target.querySelectorAll(".fc-day-header .o_cw_day_name");
assert.strictEqual(
dayHeaders[0].textContent,
"Mon 12",
"Mon",
"The first day of the week should be Monday"
);
assert.strictEqual(
dayHeaders[dayHeaders.length - 1].textContent,
"Sun 18",
"Sun",
"The last day of the week should be Sunday"
);
});
@@ -1293,7 +1293,7 @@ QUnit.module("Views", ({ beforeEach }) => {
"popover should have a close button"
);
assert.strictEqual(
target.querySelector(".o_cw_popover .list-group-item b.text-capitalize").textContent,
target.querySelector(".o_cw_popover .list-group-item span.fw-bold").textContent,
"December 14, 2016",
"should display date 'December 14, 2016'"
);
@@ -1309,8 +1309,8 @@ QUnit.module("Views", ({ beforeEach }) => {
);
assert.containsOnce(groups[0], ".o_field_char", "should apply char widget");
assert.strictEqual(
groups[0].querySelector("strong").textContent,
"Custom Name: ",
groups[0].querySelector("span.fw-bold").textContent,
"Custom Name",
"label should be a 'Custom Name'"
);
assert.strictEqual(
@@ -1320,8 +1320,8 @@ QUnit.module("Views", ({ beforeEach }) => {
);
assert.containsOnce(groups[1], ".o_form_uri", "should apply m20 widget");
assert.strictEqual(
groups[1].querySelector("strong").textContent,
"user: ",
groups[1].querySelector("span.fw-bold").textContent,
"user",
"label should be a 'user'"
);
assert.strictEqual(
@@ -1666,15 +1666,15 @@ QUnit.module("Views", ({ beforeEach }) => {
},
});
const headers = target.querySelectorAll(".fc-day-header");
const headers = target.querySelectorAll(".fc-day-header .o_cw_day_number");
assert.strictEqual(
headers[0].textContent,
"Sun 11",
"11",
"The calendar start date should be 2016-12-11"
);
assert.strictEqual(
headers[headers.length - 1].textContent,
"Sat 17",
"17",
"The calendar start date should be 2016-12-17"
);
});
@@ -3467,7 +3467,7 @@ QUnit.module("Views", ({ beforeEach }) => {
await clickEvent(target, 1);
assert.strictEqual(
target.querySelector(".o_cw_popover .list-group-item").textContent,
"December 14, 2016 (All day)"
"December 14, 2016 "
);
});
@@ -3963,13 +3963,18 @@ QUnit.module("Views", ({ beforeEach }) => {
arch: `<calendar date_start="start" date_stop="stop" mode="week"/>`,
});
assert.deepEqual(
[...target.querySelectorAll(".fc-day-header")].map((el) => el.textContent),
[...target.querySelectorAll(".fc-day-header")].map((el) =>
[
el.querySelector(".o_cw_day_name").textContent,
el.querySelector(".o_cw_day_number").textContent,
].join(" ")
),
["dim. 11", "lun. 12", "mar. 13", "mer. 14", "jeu. 15", "ven. 16", "sam. 17"]
);
});
QUnit.test(`initial_date given in the context`, async (assert) => {
assert.expect(2);
assert.expect(3);
serverData.views = {
"event,1,calendar": `<calendar date_start="start" date_stop="stop" mode="day"/>`,
"event,false,search": `<search />`,
@@ -3993,8 +3998,14 @@ QUnit.module("Views", ({ beforeEach }) => {
"should display name passed in the context"
);
assert.strictEqual(
target.querySelector(".o_calendar_renderer .fc-day-header").textContent,
"January 30, 2016",
target.querySelector(".o_calendar_renderer .fc-day-header .o_cw_day_name").textContent,
"Saturday",
"should display day passed in the context"
);
assert.strictEqual(
target.querySelector(".o_calendar_renderer .fc-day-header .o_cw_day_number")
.textContent,
"30",
"should display day passed in the context"
);
});
@@ -4027,15 +4038,15 @@ QUnit.module("Views", ({ beforeEach }) => {
},
});
const dayHeaders = target.querySelectorAll(".fc-day-header");
const dayHeaders = target.querySelectorAll(".fc-day-header .o_cw_day_name");
assert.strictEqual(
dayHeaders[0].textContent,
"Sunday",
"Sun",
"The first day of the week should be Sunday"
);
assert.strictEqual(
dayHeaders[dayHeaders.length - 1].textContent,
"Saturday",
"Sat",
"The last day of the week should be Saturday"
);
@@ -4082,15 +4093,15 @@ QUnit.module("Views", ({ beforeEach }) => {
},
});
const dayHeaders = target.querySelectorAll(".fc-day-header");
const dayHeaders = target.querySelectorAll(".fc-day-header .o_cw_day_name");
assert.strictEqual(
dayHeaders[0].textContent,
"Monday",
"Mon",
"The first day of the week should be Monday"
);
assert.strictEqual(
dayHeaders[dayHeaders.length - 1].textContent,
"Sunday",
"Sun",
"The last day of the week should be Sunday"
);
@@ -4137,14 +4148,17 @@ QUnit.module("Views", ({ beforeEach }) => {
},
});
const dayHeaders = target.querySelectorAll(".fc-day-header");
const dayNameHeaders = target.querySelectorAll(".fc-day-header .o_cw_day_name");
const dayNumberHeaders = target.querySelectorAll(".fc-day-header .o_cw_day_number");
assert.strictEqual(
dayHeaders[0].textContent,
`${dayNameHeaders[0].textContent} ${dayNumberHeaders[0].textContent}`,
"Mon 9",
"The first day of the week should be Monday the 9th"
);
assert.strictEqual(
dayHeaders[dayHeaders.length - 1].textContent,
`${dayNameHeaders[dayNameHeaders.length - 1].textContent} ${
dayNumberHeaders[dayNumberHeaders.length - 1].textContent
}`,
"Sun 15",
"The last day of the week should be Sunday the 15th"
);
@@ -4179,14 +4193,17 @@ QUnit.module("Views", ({ beforeEach }) => {
},
});
const dayHeaders = target.querySelectorAll(".fc-day-header");
const dayNameHeaders = target.querySelectorAll(".fc-day-header .o_cw_day_name");
const dayNumberHeaders = target.querySelectorAll(".fc-day-header .o_cw_day_number");
assert.strictEqual(
dayHeaders[0].textContent,
`${dayNameHeaders[0].textContent} ${dayNumberHeaders[0].textContent}`,
"Sat 7",
"The first day of the week should be Saturday the 7th"
);
assert.strictEqual(
dayHeaders[dayHeaders.length - 1].textContent,
`${dayNameHeaders[dayNameHeaders.length - 1].textContent} ${
dayNumberHeaders[dayNumberHeaders.length - 1].textContent
}`,
"Fri 13",
"The last day of the week should be Friday the 13th"
);
@@ -4398,7 +4415,7 @@ QUnit.module("Views", ({ beforeEach }) => {
assert.containsN(target, ".fc-month", 12);
assert.strictEqual(
target.querySelector(".fc-month .fc-header-toolbar").textContent,
"Jan 2016"
"January 2016"
);
assert.containsN(
target,
@@ -4445,7 +4462,7 @@ QUnit.module("Views", ({ beforeEach }) => {
.querySelector(".o_popover")
.textContent.replace(/\s{2,}/g, " ")
.trim();
assert.strictEqual(popoverText, "December 12, 201611:55 event 216:55 event 3");
assert.strictEqual(popoverText, "December 12, 201611:55event 216:55event 3");
await click(target, ".o_cw_popover_close");
assert.containsNone(target, ".o_popover");
@@ -4577,7 +4594,7 @@ QUnit.module("Views", ({ beforeEach }) => {
assert.strictEqual(
target.querySelector(".o_cw_popover .o_cw_popover_fields_secondary").textContent,
"user: name: event 4"
"usernameevent 4"
);
});
@@ -4906,6 +4923,80 @@ QUnit.module("Views", ({ beforeEach }) => {
assert.strictEqual(target.querySelector(".modal [name='properties']").textContent, "Hello");
});
QUnit.test("calendar show past events with background blur", async (assert) => {
assert.expect(2);
patchDate(2016, 11, 14, 9, 0, 0);
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `
<calendar date_start="start" mode="week"/>
`,
});
assert.containsN(target, ".fc-event", 5, "should show 5 events");
assert.containsN(target, ".fc-event.o_past_event", 4, "should show 4 past events");
});
QUnit.test("calendar sidebar state is saved on session storage", async (assert) => {
patchWithCleanup(browser, {
sessionStorage: {
setItem(key, value) {
if (key === "calendar.showSideBar") {
assert.step(`${key}-${value}`);
}
},
getItem(key) {
if (key === "calendar.showSideBar") {
assert.step(`${key}-read`);
return false;
}
},
},
});
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `
<calendar date_start="start" mode="week"/>
`,
});
assert.containsNone(target, ".o_calendar_sidebar");
await click(target, ".o_calendar_header .oi-panel-right");
assert.containsOnce(target, ".o_calendar_sidebar");
assert.verifySteps(["calendar.showSideBar-read", "calendar.showSideBar-true"]);
});
QUnit.test("calendar should show date information on header", async (assert) => {
assert.expect(6);
patchDate(2015, 11, 26, 9, 0, 0);
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `
<calendar date_start="start" mode="week"/>
`,
});
const header = target.querySelector(".o_calendar_header h5.d-inline-flex");
assert.equal(header.textContent, "December 2015 Week 52");
await changeScale(target, "day");
assert.equal(header.textContent, "26 December 2015");
await changeScale(target, "month");
assert.equal(header.textContent, "December 2015");
await changeScale(target, "year");
assert.equal(header.textContent, "2015");
await changeScale(target, "week");
await navigate(target, "next");
assert.equal(header.textContent, "December 2015 - January 2016 Week 53");
await navigate(target, "prev");
await navigate(target, "prev");
await navigate(target, "prev");
await navigate(target, "prev");
assert.equal(header.textContent, "November - December 2015 Week 49");
});
QUnit.module("CalendarView - DatePicker", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
@@ -5130,5 +5221,40 @@ QUnit.module("Views", ({ beforeEach }) => {
"4"
);
});
QUnit.test("Scale: scale default is fetched from sessionStorage", async (assert) => {
assert.expect(4);
patchWithCleanup(browser, {
sessionStorage: {
setItem(key, value) {
if (key === "calendar-scale") {
assert.step(`scale_${value}`);
}
},
getItem(key) {
if (key === "calendar-scale") {
return "month";
}
},
},
});
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `
<calendar event_open_popup="1" date_start="start" date_stop="stop" attendee="partner_ids">
<field name="partner_ids" write_field="partner_id" />
</calendar>
`,
});
assert.equal(target.querySelector(".scale_button_selection").textContent, "Month");
await changeScale(target, "year");
assert.equal(target.querySelector(".scale_button_selection").textContent, "Year");
assert.verifySteps(["scale_year"]);
});
});
});
@@ -90,18 +90,18 @@ QUnit.module("CalendarView - YearPopover", ({ beforeEach }) => {
QUnit.test("group records", async (assert) => {
await start({});
assert.containsN(target, ".o_cw_body > div", 5);
assert.containsN(target, ".o_cw_body > a", 5);
assert.containsN(target, ".o_cw_body > div", 4);
assert.containsN(target, ".o_cw_body > a", 1);
const sectionTitles = target.querySelectorAll(".o_cw_body > div");
assert.strictEqual(sectionTitles[0].textContent.trim(), "July 16, 2021");
assert.strictEqual(sectionTitles[1].textContent.trim(), "July 13-17, 2021");
assert.strictEqual(sectionTitles[2].textContent.trim(), "July 15-17, 2021");
assert.strictEqual(sectionTitles[3].textContent.trim(), "July 15-19, 2021");
assert.strictEqual(sectionTitles[0].textContent.trim(), "July 16, 2021R114:00R2");
assert.strictEqual(sectionTitles[1].textContent.trim(), "July 13-17, 2021R4");
assert.strictEqual(sectionTitles[2].textContent.trim(), "July 15-17, 2021R3");
assert.strictEqual(sectionTitles[3].textContent.trim(), "July 15-19, 2021R5");
assert.strictEqual(
target.querySelector(".o_cw_body").textContent.trim(),
"July 16, 2021R114:00 R2July 13-17, 2021R4July 15-17, 2021R3July 15-19, 2021R5 Create"
"July 16, 2021R114:00R2July 13-17, 2021R4July 15-17, 2021R3July 15-19, 2021R5 Create"
);
});
@@ -113,8 +113,8 @@ QUnit.module("CalendarView - YearPopover", ({ beforeEach }) => {
editRecord: () => assert.step("edit"),
},
});
assert.containsOnce(target, ".o_cw_body > a");
await click(target, ".o_cw_body > a");
assert.containsOnce(target, ".o_cw_body a.o_cw_popover_link");
await click(target, ".o_cw_body a.o_cw_popover_link");
assert.verifySteps(["edit"]);
});
});
@@ -34,18 +34,18 @@ QUnit.module("CalendarView - YearRenderer", ({ beforeEach }) => {
// check "title format"
assert.strictEqual(monthHeaders.length, 12);
const monthTitles = [
"Jan 2021",
"Feb 2021",
"Mar 2021",
"Apr 2021",
"January 2021",
"February 2021",
"March 2021",
"April 2021",
"May 2021",
"Jun 2021",
"Jul 2021",
"Aug 2021",
"Sep 2021",
"Oct 2021",
"Nov 2021",
"Dec 2021",
"June 2021",
"July 2021",
"August 2021",
"September 2021",
"October 2021",
"November 2021",
"December 2021",
];
for (let i = 0; i < 12; i++) {
assert.strictEqual(monthHeaders[i].textContent, monthTitles[i]);
@@ -137,18 +137,24 @@ QUnit.module("CalendarView - YearRenderer", ({ beforeEach }) => {
await selectDateRange(target, "2021-07-02", "2021-07-05");
});
QUnit.test("display correct column header for days, independent of the timezone", async (assert) => {
// Regression test: when the system tz is somewhere in a negative GMT (in our example Alaska)
// the day headers of a months were incorrectly set. (S S M T W T F) instead of (S M T W T F S)
// if the first day of the week is Sunday.
patchTimeZone(-540); // UTC-9 = Alaska
QUnit.test(
"display correct column header for days, independent of the timezone",
async (assert) => {
// Regression test: when the system tz is somewhere in a negative GMT (in our example Alaska)
// the day headers of a months were incorrectly set. (S S M T W T F) instead of (S M T W T F S)
// if the first day of the week is Sunday.
patchTimeZone(-540); // UTC-9 = Alaska
await start({});
await start({});
const dayHeaders = target
.querySelector(".fc-month-container")
.querySelectorAll(".fc-day-header");
const dayHeaders = target
.querySelector(".fc-month-container")
.querySelectorAll(".fc-day-header");
assert.deepEqual([...dayHeaders].map((el) => el.textContent), ["S", "M", "T", "W", "T", "F", "S"]);
});
assert.deepEqual(
[...dayHeaders].map((el) => el.textContent),
["S", "M", "T", "W", "T", "F", "S"]
);
}
);
});
@@ -282,9 +282,9 @@ export function findPickedDate(target) {
export async function pickDate(target, date) {
const day = date.split("-")[2];
const iDay = parseInt(day, 10) - 1;
const el = target.querySelectorAll(
`.o_datetime_picker .o_date_item_cell:not(.o_out_of_range)`
)[iDay];
const el = target.querySelectorAll(`.o_datetime_picker .o_date_item_cell:not(.o_out_of_range)`)[
iDay
];
el.scrollIntoView();
await click(el);
}