[REF] *: calendar view in owl

This commit introduces the new calendar view written in owl.

closes odoo/odoo#101185

X-original-commit: e88988f58582d5b49a32d7102a74a176f40c4c69
Related: odoo/enterprise#31808
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Signed-off-by: Bruno Boi (boi) <boi@odoo.com>
Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
This commit is contained in:
Bruno Boi
2022-09-26 22:48:44 +02:00
co-authored by Jorge Pinna Puissant
parent 3f15989e15
commit ed9a9d024a
64 changed files with 10439 additions and 361 deletions
@@ -30,7 +30,7 @@
color: #adb5bd;
}
.o_calendar_view .o_calendar_widget .fc-dayGridMonth-view {
.o_legacy_calendar_view .o_calendar_widget .fc-dayGridMonth-view {
.fc-week-number {
padding-top: 0.2rem !important;
padding-bottom: 0.3rem !important;
@@ -173,7 +173,7 @@ tour.register('project_update_tour', {
}, ...openProjectUpdateAndReturnToTasks("Pivot", "o_pivot_view"), {
trigger: '.o_switch_view.o_calendar',
content: 'Open Calendar View of Tasks',
}, ...openProjectUpdateAndReturnToTasks("Calendar", "o_calendar_view"), {
}, ...openProjectUpdateAndReturnToTasks("Calendar", "o_legacy_calendar_view"), {
trigger: '.o_switch_view.o_activity',
content: 'Open Activity View of Tasks',
}, ...openProjectUpdateAndReturnToTasks("Activity", "o_activity_view"),
+4 -2
View File
@@ -86,6 +86,7 @@ This module provides the core of the Odoo Web Client.
'web/static/lib/underscore/underscore.js',
'web/static/lib/underscore.string/lib/underscore.string.js',
'web/static/lib/moment/moment.js',
'web/static/lib/luxon/luxon.js',
'web/static/lib/owl/owl.js',
'web/static/src/owl2_compatibility/*.js',
'web/static/src/legacy/js/component_extension.js',
@@ -204,7 +205,6 @@ This module provides the core of the Odoo Web Client.
'web/static/src/env.js',
'web/static/lib/jquery.scrollTo/jquery.scrollTo.js',
'web/static/lib/luxon/luxon.js',
'web/static/lib/py.js/lib/py.js',
'web/static/lib/py.js/lib/py_extras.js',
'web/static/lib/jquery.ba-bbq/jquery.ba-bbq.js',
@@ -601,6 +601,7 @@ This module provides the core of the Odoo Web Client.
'web/static/lib/fullcalendar/daygrid/main.js',
'web/static/lib/fullcalendar/timegrid/main.js',
'web/static/lib/fullcalendar/list/main.js',
'web/static/lib/fullcalendar/luxon/main.js',
'web/static/lib/ace/ace.js',
'web/static/lib/ace/javascript_highlight_rules.js',
@@ -620,7 +621,7 @@ This module provides the core of the Odoo Web Client.
'web/static/tests/helpers/**/*.js',
'web/static/tests/views/helpers.js',
'web/static/tests/search/helpers.js',
'web/static/tests/views/helpers.js',
'web/static/tests/views/calendar/helpers.js',
'web/static/tests/webclient/**/helpers.js',
'web/static/tests/qunit.js',
'web/static/tests/main.js',
@@ -645,6 +646,7 @@ This module provides the core of the Odoo Web Client.
('remove', 'web/static/tests/search/helpers.js'),
'web/static/tests/views/**/*.js',
('remove', 'web/static/tests/views/helpers.js'),
('remove', 'web/static/tests/views/calendar/helpers.js'),
'web/static/tests/webclient/**/*.js',
('remove', 'web/static/tests/webclient/**/helpers.js'),
'web/static/tests/legacy/**/*.js',
+11
View File
@@ -168,6 +168,17 @@ export const luxonToMomentFormat = memoize(function luxonToMomentFormat(format)
// Formatting
// -----------------------------------------------------------------------------
/**
* Returns true if the given format is a 24-hour format.
* Returns false otherwise.
*
* @param {string} [format=localization.timeFormat]
* @returns true if the format contains a 24 hour format
*/
export function is24HourFormat(format) {
return (format || localization.timeFormat).indexOf("H") !== -1;
}
/**
* Formats a DateTime object to a date string
*
@@ -674,7 +674,7 @@ return AbstractRenderer.extend({
* @override
*/
async _renderView() {
this.$('.o_calendar_view')[0].prepend(this.calendarElement);
this.$('.o_legacy_calendar_view')[0].prepend(this.calendarElement);
if (this._isInDOM) {
this._renderCalendar();
}
+4 -1
View File
@@ -3,7 +3,10 @@
* all renderers. For field default rules, see the fields.scss file.
*/
.o_debug .o_legacy_form_view, .o_debug .o_legacy_kanban_view, .o_debug .o_legacy_list_view {
.o_debug .o_legacy_form_view,
.o_debug .o_legacy_kanban_view,
.o_debug .o_legacy_list_view,
.o_debug .o_legacy_calendar_view {
border-top: 3px solid red;
}
@@ -1,20 +1,4 @@
// Variables
$o-cw-color-today-accent: #FC3D39;
$o-cw-popup-avatar-size: 16px;
$o-cw-filter-avatar-size: 20px;
// Animations
@keyframes backgroundfade {
from { background-color: rgba($info, 0.5); }
to { background-color: rgba($info, 0.1); }
}
.o_calendar_container {
height: 100%;
display: flex;
}
.o_calendar_view {
.o_legacy_calendar_view {
flex: 1 1 auto;
min-width: 0;
height: 100%;
@@ -558,289 +542,11 @@ $o-cw-filter-avatar-size: 20px;
}
}
.o_calendar_sidebar_container {
flex: 0 0 auto;
position: relative;
@include o-webclient-padding($top: $o-horizontal-padding/2);
background-color: $o-view-background-color;
border-left: 1px solid $border-color;
overflow-y:auto;
.o_calendar_sidebar {
width: 200px;
font-size: 14px;
@include media-breakpoint-up('xl') {
width: 250px;
}
// sync buttons are only displayed on calendar.event views
.o_calendar_sync {
padding-top: 0.5em;
}
}
.ui-datepicker {
margin: 0;
width: 100%;
padding: 0;
&, td, .ui-datepicker-header, td a, td span {
border: 0;
}
th {
padding: .7em .2em;
width: 14%;
> span {
color: #666666;
}
}
td {
padding: 0;
a, span {
padding: 5px 0;
background: none;
text-align: center;
vertical-align: middle;
font-size: 1.2rem;
color: map-get($grays, '900');
font-weight: 400;
}
&.ui-datepicker-current-day a {
background: $info;
color: color-contrast($info);
font-weight: bold;
}
&.ui-datepicker-today a {
margin: auto;
border-radius: 100%;
padding: .1em;
width: 25px;
background: mix($o-cw-color-today-accent, white, 80%);
color: white;
}
&.ui-datepicker-current-day.ui-datepicker-today a {
background: $o-cw-color-today-accent;
}
}
.ui-datepicker-header {
background: none;
}
.ui-datepicker-header {
border-radius: 0;
.ui-datepicker-title {
color: map-get($grays, '600');
font-size: 1.2rem;
font-weight: normal;
}
.ui-icon {
background-image: none;
text-indent: 0;
color: transparent;
&:before {
font: normal normal normal 13px/1 FontAwesome;
content: "\f053";
color: map-get($grays, '400');
}
&.ui-icon-circle-triangle-e:before {
content: "\f054"
}
}
.ui-state-hover.ui-datepicker-next-hover, .ui-state-hover.ui-datepicker-prev-hover {
background: none;
border: none;
cursor: pointer;
span:before {
color: map-get($grays, '800');
}
}
}
.o_selected_range.o_color:not(.ui-datepicker-unselectable) {
background-color: $info;
animation: backgroundfade 2s forwards;
}
}
.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;
&.o_beside_avatar {
width: $o-cw-filter-avatar-size;
height: $o-cw-filter-avatar-size;
border-radius: 2px;
object-fit: cover;
}
}
input:not(:checked) + label .o_cw_filter_input_bg {
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;
}
&:hover {
button.o_remove {
transform: translateX(0%);
}
}
}
.o_field_many2one {
margin-top: 1rem;
width: 100%;
}
}
}
.o_cw_popover {
min-width: 256px;
max-width: 328px;
font-size: $font-size-base;
.card-header, .card-header .popover-header {
font-size: 1.05em;
font-weight: 500;
line-height: 1;
}
.card-footer {
background: none;
}
.o_footer_shrink {
padding-top: 0px;
padding-bottom: 0px;
}
.o_cw_popover_close {
cursor: pointer;
}
.o_calendar_avatars {
line-height: 1;
}
.o_calendar_avatars img {
margin-right: 0.4rem;
width: $o-cw-popup-avatar-size;
height: $o-cw-popup-avatar-size;
border-radius: 100%;
}
.list-group-item {
padding: 0.5rem 1rem;
border: none;
}
.o_cw_popover_fields_secondary {
max-height: 170px; // Fallback for old browsers
max-height: 25vh;
overflow: auto;
padding-bottom: 1px; // prevents the scrollbar to show when not needed
&::-webkit-scrollbar {
background: map-get($grays, '200');
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: map-get($grays, '500');
}
}
.fc-rtl & {
text-align: right;
.o_calendar_avatars {
> div {
justify-content: flex-end;
}
img {
order: 2;
margin: 0 0 0 0.4rem;
}
}
}
}
// Generated with http://phrogz.net/css/distinct-colors.html
// Do not modify the first 12 colors of this list or it will break the colors in the Time Off Calendar
$o-filter_colors: lighten(#000, 46.7%), #F06050, #F4A460, #F7CD1F, #6CC1ED, #814968, #EB7E7F, #2C8397, #475577,
#D6145F, #30C381, #9365B8, #ff1e00, #bf9600, #0003b3, #ff006a, #cc3300, #ffea00, #00cc85,
#007ab3, #6900cc, #ff0048, #c2cc00, #00ffc8, #008cff, #bf0036, #b24400, #d0ff00, #00ffea,
#0062b3, #ea00ff, #ff0026, #8bcc00, #00bfaf, #006aff, #af00bf, #bf001d, #bf6300, #8cff00,
#00f2ff, #004ab3, #ff00d0, #ffa600, #3acc00, #00b6bf, #0048ff, #bf7c00, #04ff00, #00d0ff,
#0036bf, #ff008c, #00bf49, #0092b3, #0004ff, #b20062, #649173 !default;
// =============== Generate color classes ===============
@for $i from 1 through length($o-filter_colors) {
$color: nth($o-filter_colors, $i);
.o_calendar_view .fc-view {
.o_legacy_calendar_view .fc-view {
.o_calendar_hatched_#{$i - 1} {
background: repeating-linear-gradient(
45deg,
@@ -947,39 +653,4 @@ $o-filter_colors: lighten(#000, 46.7%), #F06050, #F4A460, #F7CD1F, #6CC1ED, #814
}
}
}
.o_cw_filter_color_#{$i - 1} {
.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(white, $color, 90%);
.card-header, .card-header .popover-header {
background-color: $color-subdle;
color: color-contrast($color-subdle);
}
.card-header {
padding-left: 2px;
border-color: mix($card-border-color, mix(white, $color));
}
}
}
.modal {
.o_attendee_head {
width: 32px;
margin-right: 5px;
}
}
.o_dashboard {
.o_calendar_container .o_calendar_sidebar_container {
display: none;
}
}
@@ -1,6 +1,6 @@
<template>
<div t-name="CalendarView" class="o_calendar_container">
<div class="o_calendar_view">
<div class="o_legacy_calendar_view">
<div class="o_calendar_buttons" role="toolbar" aria-label="Calendar toolbar"/>
<div class="o_calendar_widget" />
</div>
@@ -28,6 +28,7 @@ export class ControlPanel extends Component {
this.pagerProps = this.env.config.pagerProps
? useState(this.env.config.pagerProps)
: undefined;
this.breadcrumbs = useState(this.env.config.breadcrumbs);
this.root = useRef("root");
@@ -190,7 +190,7 @@
.o_cp_bottom_right {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
justify-content: space-between;
column-gap: $o-horizontal-padding;
> .o_cp_pager {
@@ -39,8 +39,8 @@
</div>
</t>
<div class="o_cp_pager" role="search">
<Pager t-if="pagerProps and pagerProps.total > 0" t-props="pagerProps"/>
<div t-if="pagerProps and pagerProps.total > 0" class="o_cp_pager" role="search">
<Pager t-props="pagerProps"/>
</div>
<t t-if="env.config.viewSwitcherEntries.length > 1">
@@ -135,8 +135,8 @@
<t t-slot="control-panel-bottom-left"/>
</div>
<div t-if="display['bottom-right']" class="o_cp_bottom_right">
<div class="o_cp_pager" role="search">
<Pager t-if="pagerProps and pagerProps.total > 0" t-props="pagerProps"/>
<div t-if="pagerProps and pagerProps.total > 0" class="o_cp_pager" role="search">
<Pager t-props="pagerProps"/>
</div>
<t t-if="env.config.viewSwitcherEntries.length > 1">
@@ -172,8 +172,8 @@
<t t-name="web.Breadcrumbs" owl="1">
<ol class="breadcrumb">
<t t-foreach="env.config.breadcrumbs" t-as="breadcrumb" t-key="breadcrumb.jsId">
<t t-set="isPenultimate" t-value="breadcrumb_index === env.config.breadcrumbs.length - 2"/>
<t t-foreach="breadcrumbs" t-as="breadcrumb" t-key="breadcrumb.jsId">
<t t-set="isPenultimate" t-value="breadcrumb_index === breadcrumbs.length - 2"/>
<li t-if="!breadcrumb_last" class="breadcrumb-item" t-att-data-hotkey="isPenultimate and 'b'" t-att-class="{ o_back_button: isPenultimate}" t-on-click.prevent="() => this.onBreadcrumbClicked(breadcrumb.jsId)">
<a href="#">
<t t-if="breadcrumb.name" t-esc="breadcrumb.name"/>
@@ -190,7 +190,6 @@
<t t-name="web.Breadcrumbs.Small" owl="1">
<ol class="breadcrumb">
<t t-set="breadcrumbs" t-value="env.config.breadcrumbs" />
<t t-if="breadcrumbs.length > 1">
<t t-set="breadcrumb" t-value="breadcrumbs[breadcrumbs.length - 2]" />
<li class="breadcrumb-item o_back_button btn btn-secondary"
@@ -0,0 +1,195 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { XMLParser } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
import { archParseBoolean } from "@web/views/utils";
const FIELD_ATTRIBUTE_NAMES = [
"date_start",
"date_delay",
"date_stop",
"all_day",
"recurrence_update",
"create_name_field",
"color",
];
const SCALES = ["day", "week", "month", "year"];
export class CalendarParseArchError extends Error {}
export class CalendarArchParser extends XMLParser {
parse(arch, models, modelName) {
const fields = models[modelName];
const fieldNames = new Set(fields.display_name ? ["display_name"] : []);
const fieldMapping = { date_start: "date_start" };
let jsClass = null;
let eventLimit = 5;
let scales = [...SCALES];
let scale = "week";
let canCreate = true;
let canDelete = true;
let hasQuickCreate = true;
let hasEditDialog = false;
let showUnusualDays = false;
let isDateHidden = false;
let isTimeHidden = false;
let formViewId = false;
const popoverFields = {};
const filtersInfo = {};
this.visitXML(arch, (node) => {
switch (node.tagName) {
case "calendar": {
if (!node.hasAttribute("date_start")) {
throw new CalendarParseArchError(
`Calendar view has not defined "date_start" attribute.`
);
}
jsClass = node.getAttribute("js_class");
for (const fieldAttrName of FIELD_ATTRIBUTE_NAMES) {
if (node.hasAttribute(fieldAttrName)) {
const fieldName = node.getAttribute(fieldAttrName);
fieldNames.add(fieldName);
fieldMapping[fieldAttrName] = fieldName;
}
}
if (node.hasAttribute("event_limit")) {
eventLimit = evaluateExpr(node.getAttribute("event_limit"));
if (!Number.isInteger(eventLimit)) {
throw new CalendarParseArchError(
`Calendar view's event limit should be a number`
);
}
}
if (node.hasAttribute("scales")) {
const scalesAttr = node.getAttribute("scales");
scales = scalesAttr.split(",").filter((scale) => SCALES.includes(scale));
}
if (node.hasAttribute("mode")) {
scale = node.getAttribute("mode");
if (!scales.includes(scale)) {
throw new CalendarParseArchError(
`Calendar view cannot display mode: ${scale}`
);
}
}
if (node.hasAttribute("create")) {
canCreate = archParseBoolean(node.getAttribute("create"), true);
}
if (node.hasAttribute("delete")) {
canDelete = archParseBoolean(node.getAttribute("delete"), true);
}
if (node.hasAttribute("quick_add")) {
hasQuickCreate = archParseBoolean(node.getAttribute("quick_add"), true);
}
if (node.hasAttribute("event_open_popup")) {
hasEditDialog = archParseBoolean(node.getAttribute("event_open_popup"));
}
if (node.hasAttribute("show_unusual_days")) {
showUnusualDays = archParseBoolean(node.getAttribute("show_unusual_days"));
}
if (node.hasAttribute("hide_date")) {
isDateHidden = archParseBoolean(node.getAttribute("hide_date"));
}
if (node.hasAttribute("hide_time")) {
isTimeHidden = archParseBoolean(node.getAttribute("hide_time"));
}
if (node.hasAttribute("form_view_id")) {
formViewId = parseInt(node.getAttribute("form_view_id"), 10);
}
break;
}
case "field": {
const fieldName = node.getAttribute("name");
fieldNames.add(fieldName);
const fieldInfo = Field.parseFieldNode(
node,
models,
modelName,
"calendar",
jsClass
);
popoverFields[fieldName] = fieldInfo;
const field = fields[fieldName];
if (!node.hasAttribute("invisible") || node.hasAttribute("filters")) {
let filterInfo = null;
if (
node.hasAttribute("avatar_field") ||
node.hasAttribute("write_model") ||
node.hasAttribute("write_field") ||
node.hasAttribute("color") ||
node.hasAttribute("filters")
) {
filtersInfo[fieldName] = filtersInfo[fieldName] || {
avatarFieldName: null,
colorFieldName: null,
fieldName,
filterFieldName: null,
label: field.string,
resModel: field.relation,
writeFieldName: null,
writeResModel: null,
};
filterInfo = filtersInfo[fieldName];
}
if (node.hasAttribute("filter_field")) {
filterInfo.filterFieldName = node.getAttribute("filter_field");
}
if (node.hasAttribute("avatar_field")) {
filterInfo.avatarFieldName = node.getAttribute("avatar_field");
}
if (node.hasAttribute("write_model")) {
filterInfo.writeResModel = node.getAttribute("write_model");
}
if (node.hasAttribute("write_field")) {
filterInfo.writeFieldName = node.getAttribute("write_field");
}
if (node.hasAttribute("filters")) {
if (node.hasAttribute("color")) {
filterInfo.colorFieldName = node.getAttribute("color");
}
if (node.hasAttribute("avatar_field") && field.relation) {
if (
field.relation.includes([
"res.users",
"res.partners",
"hr.employee",
])
) {
filterInfo.avatarFieldName = "image_128";
}
}
}
}
break;
}
}
});
return {
canCreate,
canDelete,
eventLimit,
fieldMapping,
fieldNames: [...fieldNames],
filtersInfo,
formViewId,
hasEditDialog,
hasQuickCreate,
isDateHidden,
isTimeHidden,
popoverFields,
scale,
scales,
showUnusualDays,
};
}
}
@@ -0,0 +1,98 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { is24HourFormat } from "@web/core/l10n/dates";
import { Field } from "@web/views/fields/field";
import { Record } from "@web/views/record";
const { Component } = owl;
export class CalendarCommonPopover extends Component {
setup() {
this.time = null;
this.timeDuration = null;
this.date = null;
this.dateDuration = null;
this.computeDateTimeAndDuration();
}
get isEventEditable() {
return true;
}
get isEventDeletable() {
return this.props.model.canDelete;
}
computeDateTimeAndDuration() {
const record = this.props.record;
const { start, end } = record;
const isSameDay = start.hasSame(end, "day");
if (!record.isTimeHidden && !record.isAllDay && isSameDay) {
const timeFormat = is24HourFormat() ? "HH:mm" : "hh:mm a";
this.time = `${start.toFormat(timeFormat)} - ${end.toFormat(timeFormat)}`;
const duration = end.diff(start, ["hours", "minutes"]);
const formatParts = [];
if (duration.hours > 0) {
const hourString =
duration.hours === 1 ? this.env._t("hour") : this.env._t("hours");
formatParts.push(`h '${hourString}'`);
}
if (duration.minutes > 0) {
const minuteStr =
duration.minutes === 1 ? this.env._t("minute") : this.env._t("minutes");
formatParts.push(`m '${minuteStr}'`);
}
this.timeDuration = duration.toFormat(formatParts.join(", "));
}
if (!this.props.model.isDateHidden) {
this.date = this.getFormattedDate(start, end, record.isAllDay);
if (record.isAllDay) {
if (isSameDay) {
this.dateDuration = this.env._t("All day");
} else {
const duration = end.plus({ day: 1 }).diff(start, "days");
this.dateDuration = duration.toFormat(`d '${this.env._t("days")}'`);
}
}
}
}
getFormattedDate(start, end, isAllDay) {
const isSameDay = start.hasSame(end, "day");
if (!isSameDay && start.hasSame(end, "month")) {
// Simplify date-range if an event occurs into the same month (eg. "August, 4-5 2019")
return start.toFormat("LLLL d") + "-" + end.toFormat("d, y");
} else {
return isSameDay
? start.toFormat("DDDD")
: start.toFormat("DDD") + " - " + end.toFormat("DDD");
}
}
onEditEvent() {
this.props.editRecord(this.props.record);
this.props.close();
}
onDeleteEvent() {
this.props.deleteRecord(this.props.record);
this.props.close();
}
}
CalendarCommonPopover.components = {
Dialog,
Field,
Record,
};
CalendarCommonPopover.template = "web.CalendarCommonPopover";
CalendarCommonPopover.props = {
close: Function,
record: Object,
model: Object,
createRecord: Function,
deleteRecord: Function,
editRecord: Function,
};
@@ -0,0 +1,71 @@
// Variables
$o-cw-popup-avatar-size: 16px;
.o_cw_popover {
min-width: 256px;
max-width: 328px;
font-size: $font-size-base;
.card-header,
.card-header .popover-header {
font-size: 1.05em;
font-weight: 500;
line-height: 1;
}
.card-footer {
background: none;
}
.o_footer_shrink {
padding-top: 0px;
padding-bottom: 0px;
}
.o_cw_popover_close {
cursor: pointer;
}
.o_calendar_avatars {
line-height: 1;
}
.o_calendar_avatars img {
margin-right: 0.4rem;
width: $o-cw-popup-avatar-size;
height: $o-cw-popup-avatar-size;
border-radius: 100%;
}
.list-group-item {
padding: 0.5rem 1rem;
border: none;
}
.o_cw_popover_fields_secondary {
max-height: 170px; // Fallback for old browsers
max-height: 25vh;
overflow: auto;
padding-bottom: 1px; // prevents the scrollbar to show when not needed
&::-webkit-scrollbar {
background: map-get($grays, "200");
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: map-get($grays, "500");
}
}
.fc-rtl & {
text-align: right;
.o_calendar_avatars {
> div {
justify-content: flex-end;
}
img {
order: 2;
margin: 0 0 0 0.4rem;
}
}
}
}
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarCommonPopover" owl="1">
<t t-if="env.isSmall">
<Dialog title="props.record.title">
<t t-call="web.CalendarCommonPopover.body" />
<t t-set-slot="footer">
<t t-call="web.CalendarCommonPopover.footer" />
</t>
</Dialog>
</t>
<t t-else="">
<t t-call="web.CalendarCommonPopover.popover" />
</t>
</t>
<t t-name="web.CalendarCommonPopover.popover" owl="1">
<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="o_cw_popover_close ms-4 mt-2 me-2" t-on-click.stop="() => props.close()">
<i class="fa fa-close" />
</span>
</div>
<div class="o_cw_body">
<t t-call="web.CalendarCommonPopover.body" />
<div class="card-footer border-top" t-att-class="{ 'o_footer_shrink': isEventEditable and isEventDeletable }">
<t t-call="web.CalendarCommonPopover.footer" />
</div>
</div>
</t>
<t t-name="web.CalendarCommonPopover.body" owl="1">
<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>
</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>
</li>
</ul>
<ul class="list-group list-group-flush o_cw_popover_fields_secondary">
<Record resModel="props.model.resModel" resId="props.record.id" fields="props.model.fields" activeFields="props.model.popoverFields" mode="'readonly'" initialValues="props.record.rawRecord" t-slot-scope="slot">
<t t-foreach="slot.record.fieldNames" t-as="fieldName" t-key="fieldName">
<t t-if="!slot.record.isInvisible(fieldName)">
<t t-set="fieldInfo" t-value="props.model.popoverFields[fieldName]" />
<li class="list-group-item flex-shrink-0 d-flex flex-wrap align-items-center" t-att-class="fieldInfo.rawAttrs.class">
<strong class="me-2">
<t t-if="fieldInfo.options.icon">
<b>
<i t-att-class="fieldInfo.options.icon" />
</b>
</t>
<t t-else="">
<t t-esc="fieldInfo.string" />
:
</t>
</strong>
<div class="flex-grow-1">
<Field name="fieldName" record="slot.record" fieldInfo="fieldInfo" />
</div>
</li>
</t>
</t>
</Record>
</ul>
</t>
<t t-name="web.CalendarCommonPopover.footer" owl="1">
<t t-if="isEventEditable">
<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-2" t-on-click="onDeleteEvent">Delete</a>
</t>
</t>
</templates>
@@ -0,0 +1,296 @@
/** @odoo-module **/
import { is24HourFormat } from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
import { renderToString } from "@web/core/utils/render";
import { useDebounced } from "@web/core/utils/timing";
import { getColor } from "../colors";
import { useCalendarPopover, useClickHandler, useFullCalendar } from "../hooks";
import { CalendarCommonPopover } from "./calendar_common_popover";
const { Component, useEffect } = owl;
const SCALE_TO_FC_VIEW = {
day: "timeGridDay",
week: "timeGridWeek",
month: "dayGridMonth",
};
const SCALE_TO_HEADER_FORMAT = {
day: "DDD",
week: "EEE d",
month: "EEEE",
};
const SHORT_SCALE_TO_HEADER_FORMAT = {
...SCALE_TO_HEADER_FORMAT,
day: "D",
month: "EEE",
};
const HOUR_FORMATS = {
12: {
hour: "numeric",
minute: "2-digit",
omitZeroMinute: true,
meridiem: "short",
},
24: {
hour: "numeric",
minute: "2-digit",
hour12: false,
},
};
export class CalendarCommonRenderer extends Component {
setup() {
this.fc = useFullCalendar("fullCalendar", this.options);
this.click = useClickHandler(this.onClick, this.onDblClick);
this.popover = useCalendarPopover(this.constructor.components.Popover);
this.onWindowResizeDebounced = useDebounced(this.onWindowResize, 200);
useEffect(() => {
this.updateSize();
});
useEffect(
(view) => {
this.env.config.setDisplayName(`${this.props.displayName} (${view.title})`);
},
() => [this.fc.api.view]
);
}
get options() {
return {
allDaySlot: this.props.model.hasAllDaySlot,
allDayText: this.env._t("All day"),
columnHeaderFormat: this.env.isSmall
? SHORT_SCALE_TO_HEADER_FORMAT[this.props.model.scale]
: SCALE_TO_HEADER_FORMAT[this.props.model.scale],
dateClick: this.onDateClick,
dayRender: this.onDayRender,
defaultDate: this.props.model.date.toISO(),
defaultView: SCALE_TO_FC_VIEW[this.props.model.scale],
dir: localization.direction,
droppable: true,
editable: this.props.model.canEdit,
eventClick: this.onEventClick,
eventDragStart: this.onEventDragStart,
eventDrop: this.onEventDrop,
eventLimit: this.props.model.eventLimit,
eventLimitClick: this.onEventLimitClick,
eventMouseEnter: this.onEventMouseEnter,
eventMouseLeave: this.onEventMouseLeave,
eventRender: this.onEventRender,
eventResizableFromStart: true,
eventResize: this.onEventResize,
eventResizeStart: this.onEventResizeStart,
events: (_, successCb) => successCb(this.mapRecordsToEvents()),
firstDay: this.props.model.firstDayOfWeek % 7,
header: false,
height: "parent",
locale: luxon.Settings.defaultLocale,
longPressDelay: 500,
navLinks: false,
nowIndicator: true,
plugins: ["dayGrid", "interaction", "timeGrid", "luxon"],
select: this.onSelect,
selectAllow: this.isSelectionAllowed,
selectMinDistance: 5, // needed to not trigger select when click
selectMirror: true,
selectable: this.props.model.canCreate,
slotLabelFormat: is24HourFormat() ? HOUR_FORMATS[24] : HOUR_FORMATS[12],
snapDuration: { minutes: 15 },
timeZone: luxon.Settings.defaultZone.name,
unselectAuto: false,
weekLabel:
this.props.model.scale === "month" && this.env.isSmall ? "" : this.env._t("Week"),
weekNumberCalculation: "ISO",
weekNumbers: true,
weekNumbersWithinDays: !this.env.isSmall,
windowResize: this.onWindowResizeDebounced,
};
}
getStartTime(record) {
const timeFormat = is24HourFormat() ? "HH:mm" : "hh:mm a";
return record.start.toFormat(timeFormat);
}
computeEventSelector(event) {
return `[data-event-id="${event.id}"]`;
}
highlightEvent(event, className) {
for (const el of this.fc.el.querySelectorAll(this.computeEventSelector(event))) {
el.classList.add(className);
}
}
unhighlightEvent(event, className) {
for (const el of this.fc.el.querySelectorAll(this.computeEventSelector(event))) {
el.classList.remove(className);
}
}
mapRecordsToEvents() {
return Object.values(this.props.model.records).map((r) => this.convertRecordToEvent(r));
}
convertRecordToEvent(record) {
return {
id: record.id,
title: record.title,
start: record.start.toISO(),
end:
["week", "month"].includes(this.props.model.scale) && record.isAllDay
? record.end.plus({ days: 1 }).toISO()
: record.end.toISO(),
allDay: record.isAllDay,
};
}
getPopoverProps(record) {
return {
record,
model: this.props.model,
createRecord: this.props.createRecord,
deleteRecord: this.props.deleteRecord,
editRecord: this.props.editRecord,
};
}
openPopover(target, record) {
this.popover.open(
target,
this.getPopoverProps(record),
`o_cw_popover o_calendar_color_${record.colorIndex}`
);
}
updateSize() {
const height = window.innerHeight - this.fc.el.getBoundingClientRect().top;
this.fc.el.style.height = `${height}px`;
this.fc.api.updateSize();
}
onClick(info) {
this.openPopover(info.el, this.props.model.records[info.event.id]);
this.highlightEvent(info.event, "o_cw_custom_highlight");
}
onDateClick(info) {
if (this.env.isSmall && this.props.model.scale === "month") {
this.props.model.load({
date: luxon.DateTime.fromISO(info.dateStr),
scale: "day",
});
return;
}
this.props.createRecord(this.fcEventToRecord(info));
}
onDayRender(info) {
const date = luxon.DateTime.fromJSDate(info.date).toISODate();
if (this.props.model.unusualDays.includes(date)) {
info.el.classList.add("o_calendar_disabled");
}
}
onDblClick(info) {
this.props.editRecord(this.props.model.records[info.event.id]);
}
onEventClick(info) {
this.click(info);
}
onEventRender(info) {
const { el, event } = info;
el.dataset.eventId = event.id;
el.classList.add("o_event", "py-0");
const record = this.props.model.records[event.id];
if (record) {
// This is needed in order to give the possibility to change the event template.
const injectedContentStr = renderToString(this.constructor.eventTemplate, {
...record,
startTime: this.getStartTime(record),
});
const domParser = new DOMParser();
const { children } = domParser.parseFromString(injectedContentStr, "text/html").body;
el.querySelector(".fc-content").replaceWith(...children);
const color = getColor(record.colorIndex);
if (typeof color === "string") {
el.style.backgroundColor = color;
} else if (typeof color === "number") {
el.classList.add(`o_calendar_color_${color}`);
} else {
el.classList.add("o_calendar_color_1");
}
if (record.isHatched) {
el.classList.add("o_event_hatched");
}
if (record.isStriked) {
el.classList.add("o_event_striked");
}
}
if (!el.querySelector(".fc-bg")) {
const bg = document.createElement("div");
bg.classList.add("fc-bg");
el.appendChild(bg);
}
}
async onSelect(info) {
this.popover.close();
await this.props.createRecord(this.fcEventToRecord(info));
this.fc.api.unselect();
}
isSelectionAllowed(event) {
return event.end.getDate() === event.start.getDate() || event.allDay;
}
onEventDrop(info) {
this.fc.api.unselect();
this.props.model.updateRecord(this.fcEventToRecord(info.event), { moved: true });
}
onEventResize(info) {
this.fc.api.unselect();
this.props.model.updateRecord(this.fcEventToRecord(info.event));
}
fcEventToRecord(event) {
const { id, allDay, date, start, end } = event;
const res = {
start: luxon.DateTime.fromJSDate(date || start),
isAllDay: allDay,
};
if (end) {
res.end = luxon.DateTime.fromJSDate(end);
if (["week", "month"].includes(this.props.model.scale) && allDay) {
res.end = res.end.minus({ days: 1 });
}
}
if (id) {
res.id = this.props.model.records[id].id;
}
return res;
}
onEventMouseEnter(info) {
this.highlightEvent(info.event, "o_cw_custom_highlight");
}
onEventMouseLeave(info) {
if (!info.event.id) {
return;
}
this.unhighlightEvent(info.event, "o_cw_custom_highlight");
}
onEventDragStart(info) {
info.el.classList.add(info.view.type);
this.fc.api.unselect();
this.highlightEvent(info.event, "o_cw_custom_highlight");
}
onEventResizeStart(info) {
this.fc.api.unselect();
this.highlightEvent(info.event, "o_cw_custom_highlight");
}
onEventLimitClick() {
this.fc.api.unselect();
return "popover";
}
onWindowResize() {
this.updateSize();
}
}
CalendarCommonRenderer.components = {
Popover: CalendarCommonPopover,
};
CalendarCommonRenderer.template = "web.CalendarCommonRenderer";
CalendarCommonRenderer.eventTemplate = "web.CalendarCommonRenderer.event";
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarCommonRenderer" owl="1">
<div class="o_calendar_widget" t-ref="fullCalendar" />
</t>
<t t-name="web.CalendarCommonRenderer.event" owl="1">
<div class="fc-content">
<t t-if="!isTimeHidden">
<span class="fc-time" t-esc="startTime" />
<t> </t>
</t>
<div class="o_event_title" t-esc="title"/>
</div>
</t>
</templates>
@@ -0,0 +1,221 @@
/** @odoo-module **/
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { _lt, _t } from "@web/core/l10n/translation";
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
import { useModel } from "@web/views/model";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { useSetupView } from "@web/views/view_hook";
import { CalendarDatePicker } from "./date_picker/calendar_date_picker";
import { CalendarFilterPanel } from "./filter_panel/calendar_filter_panel";
import { CalendarMobileFilterPanel } from "./mobile_filter_panel/calendar_mobile_filter_panel";
import { CalendarQuickCreate } from "./quick_create/calendar_quick_create";
const { Component, useState } = owl;
const SCALE_LABELS = {
day: _lt("Day"),
week: _lt("Week"),
month: _lt("Month"),
year: _lt("Year"),
};
function useUniqueDialog() {
const displayDialog = useOwnedDialogs();
let close = null;
return (...args) => {
if (close) {
close();
}
close = displayDialog(...args);
};
}
export class CalendarController extends Component {
setup() {
this.action = useService("action");
this.orm = useService("orm");
this.displayDialog = useUniqueDialog();
this.model = useModel(this.props.Model, {
...this.props.archInfo,
resModel: this.props.resModel,
domain: this.props.domain,
fields: this.props.fields,
});
this.displayName = this.env.config.getDisplayName();
useSetupView({
getLocalState: () => this.model.exportedState,
});
this.state = useState({
showSideBar: !this.env.isSmall,
});
}
get rendererProps() {
return {
model: this.model,
createRecord: this.createRecord.bind(this),
deleteRecord: this.deleteRecord.bind(this),
editRecord: this.editRecord.bind(this),
setDate: this.setDate.bind(this),
displayName: this.displayName,
};
}
get containerProps() {
return {
model: this.model,
};
}
get datePickerProps() {
return {
model: this.model,
};
}
get filterPanelProps() {
return {
model: this.model,
};
}
get mobileFilterPanelProps() {
return {
model: this.model,
sideBarShown: this.state.showSideBar,
toggleSideBar: () => (this.state.showSideBar = !this.state.showSideBar),
};
}
get scaleLabels() {
return SCALE_LABELS;
}
get showCalendar() {
return !this.env.isSmall || !this.state.showSideBar;
}
get showSideBar() {
return this.state.showSideBar;
}
get className() {
return this.props.className;
}
getTodayDay() {
return luxon.DateTime.local().day;
}
async setDate(move) {
let date = null;
switch (move) {
case "next":
date = this.model.date.plus({ [`${this.model.scale}s`]: 1 });
break;
case "previous":
date = this.model.date.minus({ [`${this.model.scale}s`]: 1 });
break;
case "today":
date = luxon.DateTime.local().startOf("day");
break;
}
await this.model.load({ date });
}
async setScale(scale) {
await this.model.load({ scale });
}
getQuickCreateProps(record) {
return {
record,
model: this.model,
editRecord: this.editRecordInCreation.bind(this),
};
}
createRecord(record) {
if (!this.model.canCreate) {
return;
}
if (this.model.hasQuickCreate) {
return new Promise((resolve) => {
this.displayDialog(
this.constructor.components.QuickCreate,
this.getQuickCreateProps(record),
{ onClose: () => resolve() }
);
});
} else {
return this.editRecordInCreation(record);
}
}
async editRecord(record, context = {}, shouldFetchFormViewId = true) {
if (this.model.hasEditDialog) {
return new Promise((resolve) => {
this.displayDialog(
FormViewDialog,
{
resModel: this.model.resModel,
resId: record.id || false,
context,
title: record.id ? `${_t("Open")}: ${record.title}` : _t("New Event"),
viewId: this.model.formViewId,
onRecordSaved: () => this.model.load(),
},
{ onClose: () => resolve() }
);
});
} else {
let formViewId = this.model.formViewId;
if (shouldFetchFormViewId) {
formViewId = await this.orm.call(
this.model.resModel,
"get_formview_id",
[[record.id]],
context
);
}
const action = {
type: "ir.actions.act_window",
res_model: this.model.resModel,
views: [[formViewId || false, "form"]],
target: "current",
context,
};
if (record.id) {
action.res_id = record.id;
}
this.action.doAction(action);
}
}
editRecordInCreation(record) {
const rawRecord = this.model.buildRawRecord(record);
const context = this.model.makeContextDefaults(rawRecord);
return this.editRecord(record, context, false);
}
deleteRecord(record) {
this.displayDialog(ConfirmationDialog, {
title: this.env._t("Confirmation"),
body: this.env._t("Are you sure you want to delete this record ?"),
confirm: () => {
this.model.unlinkRecord(record.id);
},
cancel: () => {
// `ConfirmationDialog` needs this prop to display the cancel
// button but we do nothing on cancel.
},
});
}
}
CalendarController.components = {
DatePicker: CalendarDatePicker,
FilterPanel: CalendarFilterPanel,
MobileFilterPanel: CalendarMobileFilterPanel,
QuickCreate: CalendarQuickCreate,
Layout,
Dropdown,
DropdownItem,
};
CalendarController.template = "web.CalendarController";
@@ -0,0 +1,239 @@
// Variables
$o-cw-color-today-accent: #fc3d39;
$o-cw-filter-avatar-size: 20px;
// Animations
@keyframes backgroundfade {
from {
background-color: rgba($info, 0.5);
}
to {
background-color: rgba($info, 0.1);
}
}
.o_calendar_container {
height: 100%;
display: flex;
}
.o_calendar_sidebar_container {
flex: 0 0 auto;
position: relative;
@include o-webclient-padding($top: $o-horizontal-padding/2);
background-color: $o-view-background-color;
border-left: 1px solid $border-color;
overflow-y: auto;
.o_calendar_sidebar {
width: 200px;
font-size: 14px;
@include media-breakpoint-up("xl") {
width: 250px;
}
// sync buttons are only displayed on calendar.event views
.o_calendar_sync {
padding-top: 0.5em;
}
}
.ui-datepicker {
margin: 0;
width: 100%;
padding: 0;
&,
td,
.ui-datepicker-header,
td a,
td span {
border: 0;
}
th {
padding: 0.7em 0.2em;
width: 14%;
> span {
color: #666666;
}
}
td {
padding: 0;
a,
span {
padding: 5px 0;
background: none;
text-align: center;
vertical-align: middle;
font-size: 1.2rem;
color: map-get($grays, "900");
font-weight: 400;
}
&.ui-datepicker-current-day a {
background: $info;
color: color-contrast($info);
font-weight: bold;
}
&.ui-datepicker-today a {
margin: auto;
border-radius: 100%;
padding: 0.1em;
width: 25px;
background: mix($o-cw-color-today-accent, white, 80%);
color: white;
}
&.ui-datepicker-current-day.ui-datepicker-today a {
background: $o-cw-color-today-accent;
}
}
.ui-datepicker-header {
background: none;
}
.ui-datepicker-header {
border-radius: 0;
.ui-datepicker-title {
color: map-get($grays, "600");
font-size: 1.2rem;
font-weight: normal;
}
.ui-icon {
background-image: none;
text-indent: 0;
color: transparent;
&:before {
font: normal normal normal 13px/1 FontAwesome;
content: "\f053";
color: map-get($grays, "400");
}
&.ui-icon-circle-triangle-e:before {
content: "\f054";
}
}
.ui-state-hover.ui-datepicker-next-hover,
.ui-state-hover.ui-datepicker-prev-hover {
background: none;
border: none;
cursor: pointer;
span:before {
color: map-get($grays, "800");
}
}
}
.o_selected_range.o_color:not(.ui-datepicker-unselectable) {
background-color: $info;
animation: backgroundfade 2s forwards;
}
}
.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;
}
}
input:not(:checked) + label .o_cw_filter_input_bg {
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;
}
&:hover {
button.o_remove {
transform: translateX(0%);
}
}
}
.o_field_many2one {
margin-top: 1rem;
width: 100%;
}
}
}
.modal {
.o_attendee_head {
width: 32px;
margin-right: 5px;
}
}
.o_dashboard {
.o_calendar_container .o_calendar_sidebar_container {
display: none;
}
}
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarController" owl="1">
<div t-att-class="className" t-ref="root">
<Layout className="model.useSampleModel ? 'o_view_sample_data' : ''" display="props.display">
<t t-set-slot="layout-buttons">
<t t-call="{{ props.buttonTemplate }}"/>
</t>
<div class="o_calendar_container">
<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 d-md-block">
<div class="o_calendar_sidebar">
<DatePicker t-if="!env.isSmall" t-props="datePickerProps" />
<FilterPanel t-props="filterPanelProps" />
</div>
</div>
</div>
</Layout>
</div>
</t>
<t t-name="web.CalendarController.controlButtons" owl="1">
<div class="o_calendar_buttons">
<span t-if="!env.isSmall" class="o_calendar_navigation_buttons me-1">
<button
class="btn btn-primary o_calendar_button_prev"
title="Previous"
aria-label="Previous"
t-on-click.stop="() => this.setDate('previous')"
>
<i class="fa fa-arrow-left" />
</button>
<button
class="btn btn-primary mx-1 o_calendar_button_today"
t-on-click.stop="() => this.setDate('today')"
>
Today
</button>
<button
class="btn btn-primary o_calendar_button_next"
title="Next"
aria-label="Next"
t-on-click.stop="() => this.setDate('next')"
>
<i class="fa fa-arrow-right" />
</button>
</span>
<span class="o_calendar_scale_buttons">
<Dropdown class="'btn-group'" togglerClass="'btn btn-secondary scale_button_selection text-uppercase'" hotkey="'v'" showCaret="true">
<t t-set-slot="toggler">
<t t-esc="scaleLabels[model.scale]" />
</t>
<t t-foreach="model.scales" t-as="scale" t-key="scale">
<DropdownItem
class="`o_calendar_button_${scale} btn btn-secondary text-uppercase`"
hotkey="scale[0]"
onSelected="() => this.setScale(scale)"
>
<t t-esc="scaleLabels[scale]" />
</DropdownItem>
</t>
</Dropdown>
</span>
<button t-if="env.isSmall" class="o_cp_today_button btn btn-sm btn-link" t-on-click="() => this.setDate('today')">
<span class="fa-stack o_calendar_button_today">
<i class="fa fa-calendar-o fa-stack-2x" role="img" aria-label="Today" title="Today" />
<strong class="o_calendar_text fa-stack-1x" t-esc="getTodayDay()" />
</span>
</button>
</div>
</t>
</templates>
@@ -0,0 +1,48 @@
@include media-breakpoint-down(md) {
.o_control_panel {
.o_calendar_button_today {
font-size: 0.7em;
line-height: 1.9em;
> .o_calendar_text {
margin-top: 3px;
}
}
}
.o_calendar_container {
flex-direction: column;
.o_other_calendar_panel {
padding: 8px 16px;
.fa-filter {
min-height: 1.59rem;
line-height: 1.59rem;
}
> .o_filter {
> span > span {
font-size: x-small;
&:nth-child(1) {
font-size: xx-small;
}
}
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
.o_color_#{$i - 1} {
color: $color;
}
}
}
}
.o_calendar_sidebar_container {
flex-grow: 1;
> .o_calendar_sidebar {
width: auto;
}
}
}
}
@@ -0,0 +1,821 @@
/** @odoo-module **/
import {
serializeDate,
serializeDateTime,
deserializeDate,
deserializeDateTime,
} from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { KeepLast } from "@web/core/utils/concurrency";
import { Model } from "@web/views/model";
export class CalendarModel extends Model {
setup(params, services) {
/** @protected */
this.user = services.user;
/** @protected */
this.keepLast = new KeepLast();
const formViewFromConfig = (this.env.config.views || []).find((view) => view[1] === "form");
const formViewIdFromConfig = formViewFromConfig ? formViewFromConfig[0] : false;
this.meta = {
...params,
firstDayOfWeek: localization.weekStart,
formViewId: params.formViewId || formViewIdFromConfig,
};
this.data = {
filters: {},
filterSections: {},
hasCreateRight: null,
range: null,
records: {},
unusualDays: [],
};
}
async load(params = {}) {
Object.assign(this.meta, params);
if (!this.meta.date) {
this.meta.date =
params.context && params.context.initial_date
? deserializeDateTime(params.context.initial_date)
: luxon.DateTime.local();
}
const data = { ...this.data };
await this.keepLast.add(this.updateData(data));
this.data = data;
this.notify();
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
get date() {
return this.meta.date;
}
get canCreate() {
return this.meta.canCreate && this.data.hasCreateRight;
}
get canDelete() {
return this.meta.canDelete;
}
get canEdit() {
return !this.meta.fields[this.meta.fieldMapping.date_start].readonly;
}
get eventLimit() {
return this.meta.eventLimit;
}
get exportedState() {
return this.meta;
}
get fieldMapping() {
return this.meta.fieldMapping;
}
get fields() {
return this.meta.fields;
}
get filterSections() {
return Object.values(this.data.filterSections);
}
get firstDayOfWeek() {
return this.meta.firstDayOfWeek;
}
get formViewId() {
return this.meta.formViewId;
}
get hasAllDaySlot() {
return (
this.meta.fieldMapping.all_day ||
this.meta.fields[this.meta.fieldMapping.date_start].type === "date"
);
}
get hasEditDialog() {
return this.meta.hasEditDialog;
}
get hasQuickCreate() {
return this.meta.hasQuickCreate;
}
get isDateHidden() {
return this.meta.isDateHidden;
}
get isTimeHidden() {
return this.meta.isTimeHidden;
}
get popoverFields() {
return this.meta.popoverFields;
}
get rangeEnd() {
return this.data.range.end;
}
get rangeStart() {
return this.data.range.start;
}
get records() {
return this.data.records;
}
get resModel() {
return this.meta.resModel;
}
get scale() {
return this.meta.scale;
}
get scales() {
return this.meta.scales;
}
get unusualDays() {
return this.data.unusualDays;
}
//--------------------------------------------------------------------------
async createFilter(fieldName, filterValue) {
const info = this.meta.filtersInfo[fieldName];
if (info && info.writeFieldName && info.writeResModel) {
const data = {
user_id: this.user.userId,
[info.writeFieldName]: filterValue,
};
if (info.filterFieldName) {
data[info.filterFieldName] = true;
}
await this.orm.create(info.writeResModel, [data]);
await this.load();
}
}
async createRecord(record) {
const rawRecord = this.buildRawRecord(record);
const context = this.makeContextDefaults(rawRecord);
await this.orm.create(this.meta.resModel, [rawRecord], { context });
await this.load();
}
async unlinkFilter(fieldName, recordId) {
const info = this.meta.filtersInfo[fieldName];
if (info && info.writeResModel) {
await this.orm.unlink(info.writeResModel, [recordId]);
await this.load();
}
}
async unlinkRecord(recordId) {
await this.orm.unlink(this.meta.resModel, [recordId]);
await this.load();
}
async updateFilters(fieldName, filters) {
const section = this.data.filterSections[fieldName];
if (section) {
for (const value in filters) {
const active = filters[value];
const filter = section.filters.find((filter) => filter.value == value);
if (filter) {
filter.active = active;
const info = this.meta.filtersInfo[fieldName];
if (
filter.recordId &&
info &&
info.writeFieldName &&
info.writeResModel &&
info.filterFieldName
) {
const data = {
[info.filterFieldName]: active,
};
await this.orm.write(info.writeResModel, [filter.recordId], data);
}
}
}
}
await this.load();
}
async updateRecord(record, options = {}) {
const rawRecord = this.buildRawRecord(record, options);
delete rawRecord.name; // name is immutable.
await this.orm.write(this.meta.resModel, [record.id], rawRecord, {
context: { from_ui: true },
});
await this.load();
}
//--------------------------------------------------------------------------
buildRawRecord(partialRecord, options = {}) {
const data = {};
data[this.meta.fieldMapping.create_name_field || "name"] = partialRecord.title;
let start = partialRecord.start;
let end = partialRecord.end;
if (!end || !end.isValid) {
// Set end date if not existing
if (partialRecord.isAllDay) {
end = start;
} else {
// in week mode or day mode, convert allday event to event
end = start.plus({ hours: 2 });
}
}
const isDateEvent = this.fields[this.meta.fieldMapping.date_start].type === "date";
// An "all day" event without the "all_day" option is not considered
// as a 24h day. It's just a part of the day (by default: 7h-19h).
if (partialRecord.isAllDay) {
if (!this.hasAllDaySlot && !isDateEvent && !partialRecord.id) {
// default hours in the user's timezone
start = start.set({ hours: 7 });
end = end.set({ hours: 19 });
}
}
if (this.meta.fieldMapping.all_day) {
data[this.meta.fieldMapping.all_day] = partialRecord.isAllDay;
}
data[this.meta.fieldMapping.date_start] =
(partialRecord.isAllDay && this.hasAllDaySlot
? "date"
: this.fields[this.meta.fieldMapping.date_start].type) === "date"
? serializeDate(start)
: serializeDateTime(start);
if (this.meta.fieldMapping.date_stop) {
data[this.meta.fieldMapping.date_stop] =
(partialRecord.isAllDay && this.hasAllDaySlot
? "date"
: this.fields[this.meta.fieldMapping.date_start].type) === "date"
? serializeDate(end)
: serializeDateTime(end);
}
if (this.meta.fieldMapping.date_delay) {
if (this.meta.scale !== "month" || !options.moved) {
data[this.meta.fieldMapping.date_delay] = end.diff(start, "hours").hours;
}
}
return data;
}
makeContextDefaults(rawRecord) {
const { fieldMapping, scale } = this.meta;
const context = { ...this.meta.context };
const fieldNames = [
fieldMapping.create_name_field || "name",
fieldMapping.date_start,
fieldMapping.date_stop,
fieldMapping.date_delay,
fieldMapping.all_day || "allday",
];
for (const fieldName of fieldNames) {
// fieldName could be in rawRecord but not defined
if (rawRecord[fieldName] !== undefined) {
context[`default_${fieldName}`] = rawRecord[fieldName];
}
}
if (["month", "year"].includes(scale)) {
context[`default_${fieldMapping.all_day || "allday"}`] = true;
}
return context;
}
//--------------------------------------------------------------------------
// Protected
//--------------------------------------------------------------------------
/**
* @protected
*/
async updateData(data) {
if (data.hasCreateRight === null) {
data.hasCreateRight = await this.orm.call(this.meta.resModel, "check_access_rights", [
"create",
false,
]);
}
data.range = this.computeRange();
if (this.meta.showUnusualDays) {
data.unusualDays = await this.loadUnusualDays(data);
}
const { sections, dynamicFiltersInfo } = await this.loadFilters(data);
// Load records and dynamic filters only with fresh filters
data.filterSections = sections;
data.records = await this.loadRecords(data);
const dynamicSections = await this.loadDynamicFilters(data, dynamicFiltersInfo);
// Apply newly computed filter sections
Object.assign(data.filterSections, dynamicSections);
// Remove records that don't match dynamic filters
for (const [recordId, record] of Object.entries(data.records)) {
for (const [fieldName, filterInfo] of Object.entries(dynamicSections)) {
for (const filter of filterInfo.filters) {
const rawValue = record.rawRecord[fieldName];
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
if (filter.value === value && !filter.active) {
delete data.records[recordId];
}
}
}
}
}
//--------------------------------------------------------------------------
/**
* @protected
*/
computeRange() {
const { scale, date, firstDayOfWeek } = this.meta;
let start = date;
let end = date;
if (scale !== "week") {
// startOf("week") does not depend on locale and will always give the
// "Monday" of the week...
start = start.startOf(scale);
end = end.endOf(scale);
}
if (["week", "month"].includes(scale)) {
const weekday = start.weekday < firstDayOfWeek ? firstDayOfWeek - 7 : firstDayOfWeek;
start = start.set({ weekday });
end = start.plus({ weeks: scale === "week" ? 1 : 6, days: -1 });
}
start = start.startOf("day");
end = end.endOf("day");
return { start, end };
}
//--------------------------------------------------------------------------
/**
* @protected
*/
computeDomain(data) {
return [
...this.meta.domain,
...this.computeRangeDomain(data),
...this.computeFiltersDomain(data),
];
}
/**
* @protected
*/
computeFiltersDomain(data) {
// List authorized values for every field
// fields with an active "all" filter are skipped
const authorizedValues = {};
const avoidValues = {};
for (const [fieldName, filterSection] of Object.entries(data.filterSections)) {
// Skip "all" filters because they do not affect the domain
const filterAll = filterSection.filters.find((f) => f.type === "all");
if (!(filterAll && filterAll.active)) {
const filterSectionInfo = this.meta.filtersInfo[fieldName];
// Loop over subfilters to complete authorizedValues
for (const filter of filterSection.filters) {
if (filterSectionInfo.writeResModel) {
if (!authorizedValues[fieldName]) {
authorizedValues[fieldName] = [];
}
if (filter.active) {
authorizedValues[fieldName].push(filter.value);
}
} else {
if (!filter.active) {
if (!avoidValues[fieldName]) {
avoidValues[fieldName] = [];
}
avoidValues[fieldName].push(filter.value);
}
}
}
}
}
// Compute the domain
const domain = [];
for (const field in authorizedValues) {
domain.push([field, "in", authorizedValues[field]]);
}
for (const field in avoidValues) {
if (avoidValues[field].length > 0) {
domain.push([field, "not in", avoidValues[field]]);
}
}
return domain;
}
/**
* @protected
*/
computeRangeDomain(data) {
const { fieldMapping } = this.meta;
const formattedEnd = serializeDateTime(data.range.end);
const formattedStart = serializeDateTime(data.range.start);
const domain = [[fieldMapping.date_start, "<=", formattedEnd]];
if (fieldMapping.date_stop) {
domain.push([fieldMapping.date_stop, ">=", formattedStart]);
} else if (!fieldMapping.date_delay) {
domain.push([fieldMapping.date_start, ">=", formattedStart]);
}
return domain;
}
//--------------------------------------------------------------------------
/**
* @protected
*/
fetchUnusualDays(data) {
return this.orm.call(this.meta.resModel, "get_unusual_days", [
serializeDateTime(data.range.start),
serializeDateTime(data.range.end),
]);
}
/**
* @protected
*/
async loadUnusualDays(data) {
const unusualDays = await this.fetchUnusualDays(data);
return Object.entries(unusualDays)
.filter((entry) => entry[1])
.map((entry) => entry[0]);
}
//--------------------------------------------------------------------------
/**
* @protected
*/
fetchRecords(data) {
const { fieldNames, resModel } = this.meta;
return this.orm.searchRead(resModel, this.computeDomain(data), fieldNames);
}
/**
* @protected
*/
async loadRecords(data) {
const rawRecords = await this.fetchRecords(data);
const records = {};
for (const rawRecord of rawRecords) {
records[rawRecord.id] = this.normalizeRecord(rawRecord);
}
return records;
}
/**
* @protected
* @param {Record<string, any>} rawRecord
*/
normalizeRecord(rawRecord) {
const { fields, fieldMapping, filtersInfo, isTimeHidden, scale } = this.meta;
const startType = fields[fieldMapping.date_start].type;
let start =
startType === "date"
? deserializeDate(rawRecord[fieldMapping.date_start])
: deserializeDateTime(rawRecord[fieldMapping.date_start]);
let end = start;
let endType = startType;
if (fieldMapping.date_stop) {
endType = fields[fieldMapping.date_stop].type;
end =
endType === "date"
? deserializeDate(rawRecord[fieldMapping.date_stop])
: deserializeDateTime(rawRecord[fieldMapping.date_stop]);
}
const duration = rawRecord[fieldMapping.date_delay] || 1;
const isAllDay =
startType === "date" ||
(fieldMapping.all_day && rawRecord[fieldMapping.all_day]) ||
false;
if (isAllDay) {
start = start.startOf("day");
end = end.startOf("day");
}
if (!fieldMapping.date_stop && duration) {
end = start.plus({ hours: duration });
}
const showTime =
!(fieldMapping.all_day && rawRecord[fieldMapping.all_day]) &&
scale === "month" &&
startType !== "date" &&
start.day === end.day;
let colorValue = rawRecord[fieldMapping.color];
let colorIndex = Array.isArray(colorValue) ? colorValue[0] : colorValue;
const filterInfo = Object.values(filtersInfo).find((info) => info.colorFieldName);
if (filterInfo && filterInfo.colorFieldName) {
colorValue = rawRecord[filterInfo.colorFieldName];
colorIndex = Array.isArray(colorValue) ? colorValue[0] : colorValue;
}
const title = rawRecord[fieldMapping.create_name_field || "display_name"];
return {
id: rawRecord.id,
title,
isAllDay,
start,
startType,
end,
endType,
duration,
colorIndex,
isHatched: rawRecord["is_hatched"] || false,
isStriked: rawRecord["is_striked"] || false,
isTimeHidden: isTimeHidden || !showTime,
rawRecord,
};
}
//--------------------------------------------------------------------------
/**
* @protected
*/
loadFilterColor(filterInfo, fieldName, rawRecord, color) {
const { fields, fieldMapping } = this.meta;
const field = fields[fieldName];
const colorField = fields[fieldMapping.color];
const relatedField = `${fieldName}.${filterInfo.colorFieldName}`;
let rawValue = null;
if (
!!colorField &&
(field.relation === colorField.relation || colorField.related === relatedField)
) {
if (rawRecord[fieldName]) {
// The color field is a related field of the current field.
rawValue = color ? color[fieldMapping.color] : rawRecord[fieldName][0];
} else {
// Re use the color of the record
rawValue = rawRecord[fieldMapping.color];
}
}
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
return value || null;
}
/**
* @protected
*/
fetchFilters(resModel, fieldNames) {
return this.orm.searchRead(resModel, [["user_id", "=", this.user.userId]], fieldNames);
}
/**
* @protected
*/
async loadFilters(data) {
const previousSections = data.filterSections;
const sections = {};
const dynamicFiltersInfo = {};
for (const [fieldName, filterInfo] of Object.entries(this.meta.filtersInfo)) {
const previousSection = previousSections[fieldName];
if (filterInfo.writeResModel) {
sections[fieldName] = await this.loadFilterSection(
fieldName,
filterInfo,
previousSection
);
} else {
dynamicFiltersInfo[fieldName] = { filterInfo, previousSection };
}
}
return { sections, dynamicFiltersInfo };
}
/**
* @protected
*/
async loadFilterSection(fieldName, filterInfo, previousSection) {
const fields = [filterInfo.writeFieldName, filterInfo.filterFieldName].filter(Boolean);
const rawRecords = await this.fetchFilters(filterInfo.writeResModel, fields);
const previousFilters = previousSection ? previousSection.filters : [];
const filters = rawRecords.map((r) => {
const previousRecordFilter = previousFilters.find(
(f) => f.type === "record" && f.recordId === r.id
);
return this.makeFilterRecord(
filterInfo,
previousRecordFilter,
r,
filterInfo.writeFieldName,
filterInfo.filterFieldName
);
});
const field = this.meta.fields[fieldName];
const isUserOrPartner = ["res.users", "res.partner"].includes(field.relation);
if (isUserOrPartner) {
const previousUserFilter = previousFilters.find((f) => f.type === "user");
filters.push(
this.makeFilterUser(filterInfo, previousUserFilter, fieldName, rawRecords)
);
}
const previousAllFilter = previousFilters.find((f) => f.type === "all");
filters.push(this.makeFilterAll(previousAllFilter, isUserOrPartner));
return {
label: filterInfo.label,
fieldName,
filters,
avatar: {
field: filterInfo.avatarFieldName,
model: filterInfo.resModel,
},
hasAvatar: !!filterInfo.avatarFieldName,
write: {
field: filterInfo.writeFieldName,
model: filterInfo.writeResModel,
},
canCollapse: filters.length > 2,
canAddFilter: !!filterInfo.writeResModel,
};
}
/**
* @protected
*/
async loadDynamicFilters(data, filtersInfo) {
const fieldNames = Object.keys(filtersInfo);
if (this.meta.fieldMapping.color) {
fieldNames.push(this.meta.fieldMapping.color);
}
const sections = {};
for (const [fieldName, { filterInfo, previousSection }] of Object.entries(filtersInfo)) {
sections[fieldName] = await this.loadDynamicFilterSection(
data,
fieldName,
filterInfo,
previousSection
);
}
return sections;
}
/**
* @protected
*/
async loadDynamicFilterSection(data, fieldName, filterInfo, previousSection) {
const { fields, fieldMapping } = this.meta;
const field = fields[fieldName];
const previousFilters = previousSection ? previousSection.filters : [];
const treatedValues = new Map();
const relatedIds = new Set();
for (const record of Object.values(data.records)) {
if (record.rawRecord[fieldName]) {
relatedIds.add(record.rawRecord[fieldName][0]);
}
const rawValues = ["many2many", "one2many"].includes(field.type)
? record.rawRecord[fieldName]
: [record.rawRecord[fieldName]];
for (const rawValue of rawValues) {
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
if (!treatedValues.has(value)) {
treatedValues.set(value, { record, rawValue });
}
}
}
// TODO: refactor this and merge in with loadFilterColor
// in order to simplify makeFilterDynamic
const rawColors =
filterInfo.colorFieldName && relatedIds.size
? await this.orm.searchRead(
fields[fieldName].relation,
[["id", "in", Array.from(relatedIds)]],
[fieldMapping.color]
)
: [];
const filters = Array.from(treatedValues).map(([value, { record, rawValue }]) => {
const color = rawColors.find((r) => r.id === value);
const previousDynamicFilter = previousFilters.find(
(f) => f.type === "dynamic" && f.value === value
);
return this.makeFilterDynamic(
filterInfo,
previousDynamicFilter,
rawValue,
fieldName,
record,
color
);
});
return {
label: filterInfo.label,
fieldName,
filters,
avatar: {
field: filterInfo.avatarFieldName,
model: filterInfo.resModel,
},
hasAvatar: !!filterInfo.avatarFieldName,
write: {
field: filterInfo.writeFieldName,
model: filterInfo.writeResModel,
},
canCollapse: filters.length > 2,
canAddFilter: !!filterInfo.writeResModel,
};
}
/**
* @protected
*/
makeFilterDynamic(filterInfo, previousFilter, rawValue, fieldName, record, color) {
const field = this.meta.fields[fieldName];
const formatter = registry.category("formatters").get(field.type);
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
return {
type: "dynamic",
recordId: null,
value,
label: formatter(rawValue) || _t("Undefined"),
active: previousFilter ? previousFilter.active : true,
canRemove: false,
colorIndex: this.loadFilterColor(filterInfo, fieldName, record.rawRecord, color),
hasAvatar: !!value,
};
}
/**
* @protected
*/
makeFilterRecord(filterInfo, previousFilter, record, fieldName, filterFieldName) {
const raw = record[fieldName];
const value = Array.isArray(raw) ? raw[0] : raw;
const field = this.meta.fields[fieldName];
const isX2Many = ["many2many", "one2many"].includes(field.type);
const formatter = registry.category("formatters").get(isX2Many ? "many2one" : field.type);
let active = false;
if (previousFilter) {
active = previousFilter.active;
} else if (filterFieldName) {
active = record[filterFieldName];
}
return {
type: "record",
recordId: record.id,
value,
label: formatter(raw),
active,
canRemove: true,
colorIndex: this.loadFilterColor(filterInfo, fieldName, record),
hasAvatar: !!value,
};
}
/**
* @protected
*/
makeFilterUser(filterInfo, previousFilter, fieldName, rawRecords) {
const field = this.meta.fields[fieldName];
const userFieldName = field.relation === "res.partner" ? "partnerId" : "userId";
const value = this.user[userFieldName];
return {
type: "user",
recordId: null,
value,
label: this.user.name,
active: previousFilter ? previousFilter.active : true,
canRemove: false,
colorIndex: this.loadFilterColor(
filterInfo,
fieldName,
rawRecords.find((r) => r.id === value) || {}
),
hasAvatar: !!value,
};
}
/**
* @protected
*/
makeFilterAll(previousAllFilter, isUserOrPartner) {
return {
type: "all",
recordId: null,
value: "all",
label: isUserOrPartner
? this.env._t("Everybody's calendars")
: this.env._t("Everything"),
active: previousAllFilter ? previousAllFilter.active : false,
canRemove: false,
colorIndex: null,
hasAvatar: false,
};
}
}
CalendarModel.services = ["user"];
@@ -0,0 +1,37 @@
/** @odoo-module **/
import { ActionSwiper } from "@web/core/action_swiper/action_swiper";
import { CalendarCommonRenderer } from "./calendar_common/calendar_common_renderer";
import { CalendarYearRenderer } from "./calendar_year/calendar_year_renderer";
const { Component } = owl;
export class CalendarRenderer extends Component {
get calendarComponent() {
return this.constructor.components[this.props.model.scale];
}
get calendarKey() {
return `${this.props.model.scale}_${this.props.model.date.valueOf()}`;
}
get actionSwiperProps() {
return {
onLeftSwipe: this.env.isSmall
? { action: () => this.props.setDate("next") }
: undefined,
onRightSwipe: this.env.isSmall
? { action: () => this.props.setDate("previous") }
: undefined,
animationOnMove: false,
animationType: "forwards",
swipeDistanceRatio: 6,
};
}
}
CalendarRenderer.components = {
day: CalendarCommonRenderer,
week: CalendarCommonRenderer,
month: CalendarCommonRenderer,
year: CalendarYearRenderer,
ActionSwiper,
};
CalendarRenderer.template = "web.CalendarRenderer";
@@ -0,0 +1,697 @@
.o_calendar_renderer {
flex: 1 1 auto;
min-width: 0;
height: 100%;
background-color: map-get($grays, "100");
background: linear-gradient(-45deg, map-get($grays, "100"), white);
.fc-event {
margin: 0 1px;
border-style: solid;
border-width: 0 0 0 3px;
box-sizing: border-box;
overflow: hidden;
background: none;
font-size: 11px;
line-height: 1;
&:not([href]):not([tabindex]) {
color: $body-color;
}
&.fc-dragging.fc-day-grid-event.dayGridMonth .fc-content {
@include text-truncate();
margin: 4px 4px 3px;
}
.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;
}
.fc-content {
white-space: normal;
margin: 0.25rem;
font-size: 1.1em;
font-weight: 500;
}
// 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;
}
}
}
.o_calendar_widget {
height: 100%;
> .fc-view-container {
height: 100%;
}
// === Adapt calendar table borders ===
// =====================================
td {
border-color: $border-color;
}
.fc-time-grid .fc-slats .fc-minor td {
border-top-color: map-get($grays, "400");
}
.fc-widget-content {
border-left-color: transparent;
}
.fc-widget-header {
border-color: transparent;
border-bottom-color: $border-color;
padding: 3px 0 5px;
}
hr.fc-widget-header {
padding: 1px;
border: 0;
background: map-get($grays, "400");
}
.fc-timeGrid-view .fc-day-grid .fc-row .fc-content-skeleton {
padding: 0.5em;
}
.fc-event-container {
color: white;
}
.fc-more-popover {
.fc-header {
padding-left: 1rem;
.fc-title {
font-weight: bold;
}
}
.fc-body {
max-height: 500px;
overflow: auto;
}
}
.o_calendar_disabled {
background-color: $gray-200;
}
// ====== Specific agenda types ======
// ====================================
// ====== Both Day and Week agenda
.fc-timeGridDay-view,
.fc-timeGridWeek-view {
.fc-axis {
padding-left: $o-horizontal-padding;
}
.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;
span {
max-width: 45px;
margin-top: -19px;
position: relative;
display: block;
}
}
// Add a small gap on top to show the first time label (0:00)
.fc-scroller .fc-time-grid > .fc-slats,
.fc-scroller .fc-time-grid > .fc-bg {
padding-top: 15px;
}
// 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);
+ hr.fc-widget-header {
padding: 1px 0 0;
background: map-get($grays, "300");
}
.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.
.fc-slats tr:first-child td.fc-widget-content:last-child {
box-shadow: inset 0 1px 0 map-get($grays, "400");
}
.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 {
// Prevent events with similar color to visually overlap each other
box-shadow: 0 0 0 1px white;
&.fc-event:not(.fc-h-event) {
border-width: 3px 0 0;
&.fc-not-start {
border-width: 0 0 3px;
&.fc-not-end {
border-width: 0;
}
}
}
}
// Reset position to keep the "nowIndicator" line visible
.fc-content-col {
position: initial;
}
}
// ====== 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;
}
.fc-content {
margin-top: 1px !important;
}
}
// ====== Week only
.fc-timeGridWeek-view {
.fc-now-indicator {
left: $o-horizontal-padding;
}
// Expand tiny events on hover/select
.fc-event:not(.fc-h-event).o_cw_custom_highlight,
.fc-event:not(.fc-h-event).o_cw_custom_hover {
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 {
padding-left: $o-horizontal-padding;
.fc-event {
border-radius: 25px;
}
.fc-widget-header {
padding: 3px 0;
}
.fc-week-number {
background: none;
font-size: 1.2rem;
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;
font-size: 1.2rem;
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;
}
.fc-content-skeleton .fc-today .fc-day-number {
margin-top: 3px;
padding: 0.4em 0.4em 0.35em;
border-radius: 100%;
min-width: 1.1em;
background: $o-cw-color-today-accent;
text-align: center;
color: white;
font-size: 1.1rem;
}
.fc-more-cell {
> div,
.fc-more {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
.fc-event {
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();
}
&.o_cw_nobg {
&.o_cw_custom_hover,
&.o_cw_custom_highlight,
&:hover {
.fc-bg {
visibility: visible;
}
}
}
&.fc-not-start {
border-right-width: 3px;
.fc-content {
padding-left: 6px;
}
}
&.fc-not-end {
margin-right: 0;
.fc-content {
padding-right: 6px;
}
}
}
}
// ====== Year only
.fc-dayGridYear-view {
border: none;
height: 100%;
padding-left: $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;
}
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
.fc-bgevent.o_calendar_striked {
background: linear-gradient(transparent 0 40%, $color 40% 60%, transparent 60% 100%);
}
.o_cw_popover_link.o_calendar_color_#{$i - 1} {
border: solid 2px;
padding-left: 5px;
border-color: $color;
margin-top: 2px;
background: $color;
color: black;
&.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_attendee_status_needsAction {
background-color: rgba(white, 0.9) !important;
}
&.o_attendee_status_declined {
text-decoration: line-through;
background-color: rgba(white, 0.9) !important;
}
}
}
> table {
height: 100%;
}
table,
tr,
th,
td {
border: none;
}
&.fc-readonly-year-view {
.fc-day-top:not(.fc-has-event) {
cursor: default !important;
}
}
&:not(.fc-readonly-year-view) {
.fc-day-top:hover > .fc-day-number {
font-weight: bold;
border-radius: 100%;
text-align: center;
background-color: rgba(#87c0d1, 0.5);
color: map-get($grays, "900");
}
}
> .fc-month-container {
width: 25%;
min-width: 25rem;
box-sizing: border-box;
@include media-breakpoint-down(md) {
width: 50%;
min-width: 12rem;
}
> .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;
h2 {
font-size: 1.2rem;
color: map-get($grays, "600");
}
}
.fc-widget-header {
padding: 2px 0;
cursor: default;
}
.fc-dayGridMonth-view {
padding-left: unset;
.fc-has-event {
background-color: #b4dff5;
}
}
.fc-week.fc-row {
min-height: 2rem;
}
.fc-disabled-day {
background-color: unset;
}
.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;
}
}
}
}
}
// ====== RTL layout(s)
&.fc-rtl {
.fc-timeGrid-view .fc-event {
border-width: 0 3px 0 0;
}
.fc-dayGridMonth-view .fc-event {
border-width: 0 3px 0 0;
&.fc-not-start {
margin: 0 0 1px 5px;
border-width: 0 0 0 3px;
.fc-content {
padding-right: 6px;
padding-left: 0;
}
}
&.fc-not-end {
margin: 0 5px 1px 0;
.fc-content {
padding-left: 6px;
padding-right: 0;
}
}
}
}
}
}
// Generated with http://phrogz.net/css/distinct-colors.html
// Do not modify the first 12 colors of this list or it will break the colors in the Time Off Calendar
$o-filter_colors: lighten(#000, 46.7%), #f06050, #f4a460, #f7cd1f, #6cc1ed, #814968, #eb7e7f, #2c8397, #475577, #d6145f,
#30c381, #9365b8, #ff1e00, #bf9600, #0003b3, #ff006a, #cc3300, #ffea00, #00cc85, #007ab3, #6900cc, #ff0048, #c2cc00,
#00ffc8, #008cff, #bf0036, #b24400, #d0ff00, #00ffea, #0062b3, #ea00ff, #ff0026, #8bcc00, #00bfaf, #006aff, #af00bf,
#bf001d, #bf6300, #8cff00, #00f2ff, #004ab3, #ff00d0, #ffa600, #3acc00, #00b6bf, #0048ff, #bf7c00, #04ff00, #00d0ff,
#0036bf, #ff008c, #00bf49, #0092b3, #0004ff, #b20062, #649173 !default;
// =============== Generate color classes ===============
@for $i from 1 through length($o-filter_colors) {
$color: nth($o-filter_colors, $i);
.o_calendar_renderer .fc-view {
.o_calendar_hatched_#{$i - 1} {
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_calendar_striked_#{$i - 1} {
background: linear-gradient(transparent 0 45%, $color 45% 55%, transparent 55% 100%);
.fc-bg {
background: none;
}
}
.fc-bgevent.o_calendar_color_#{$i - 1} {
border-color: $color;
background-color: $color;
opacity: 0.5;
}
.fc-event.o_calendar_striked_#{$i - 1} {
border: none;
}
.fc-event.o_calendar_color_#{$i - 1} {
border-color: $color;
color: darken($color, 45%);
opacity: 1;
&.o_event_highlight {
opacity: 1;
.fc-content {
font-weight: bold;
}
}
&:not(.o_calendar_hatched):not(.o_calendar_striked) {
background: scale-color($color, $lightness: 50%);
.fc-bg {
background: scale-color($color, $lightness: 50%);
}
}
&.o_cw_custom_hover,
&.o_cw_custom_highlight {
box-shadow: 0 12px 12px -5px rgba($color, 0.3);
color: color-contrast($color);
z-index: 10 !important;
opacity: 1;
right: 1px !important;
left: 1px !important;
margin-right: 0 !important;
}
&.o_attendee_status_needsAction,
&.o_attendee_status_tentative,
&.o_attendee_status_declined,
&.o_attendee_status_alone {
border-width: 2px 2px 2px !important;
&.o_cw_custom_highlight {
background-color: $color;
}
}
&.o_attendee_status_tentative {
color: color-contrast($color);
.fc-bg {
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba(white, 0.4) 10px,
rgba(white, 0.4) 20px
) !important;
}
}
&.o_attendee_status_alone,
&.o_attendee_status_needsAction {
background-color: rgba(white, 0.9) !important;
color: black;
.fc-bg {
background-color: rgba(white, 0.9) !important;
}
}
&.o_attendee_status_declined {
text-decoration: line-through;
background-color: rgba(white, 0.9) !important;
color: black;
.fc-bg {
background-color: rgba(white, 0.9) !important;
}
}
}
}
.o_cw_filter_color_#{$i - 1} {
.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(white, $color, 90%);
.card-header,
.card-header .popover-header {
background-color: $color-subdle;
color: color-contrast($color-subdle);
}
.card-header {
padding-left: 2px;
border-color: mix($card-border-color, mix(white, $color));
}
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarRenderer" owl="1">
<div class="o_calendar_renderer">
<ActionSwiper t-props="actionSwiperProps">
<t
t-component="calendarComponent"
t-props="props"
t-key="calendarKey"
/>
</ActionSwiper>
</div>
</t>
</templates>
@@ -0,0 +1,34 @@
@include media-breakpoint-down(md) {
.o_calendar_renderer {
.fc-view {
&.fc-timeGrid-view {
.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-dayGridMonth-view {
padding-left: 0;
.fc-week-number:not(.fc-widget-header) {
background-color: $gray-400;
color: $gray-800;
}
}
}
.fc-more-popover .fc-close {
padding: 5px;
}
}
}
@@ -0,0 +1,38 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { CalendarRenderer } from "./calendar_renderer";
import { CalendarArchParser } from "./calendar_arch_parser";
import { CalendarModel } from "./calendar_model";
import { CalendarController } from "./calendar_controller";
export const calendarView = {
type: "calendar",
display_name: "Calendar",
icon: "fa fa-calendar",
multiRecord: true,
searchMenuTypes: ["filter", "favorite"],
ArchParser: CalendarArchParser,
Controller: CalendarController,
Model: CalendarModel,
Renderer: CalendarRenderer,
buttonTemplate: "web.CalendarController.controlButtons",
props: (props, view) => {
const { ArchParser } = view;
const { arch, relatedModels, resModel } = props;
const archInfo = new ArchParser().parse(arch, relatedModels, resModel);
return {
...props,
Model: view.Model,
Renderer: view.Renderer,
buttonTemplate: view.buttonTemplate,
archInfo,
};
},
};
registry.category("views").add("calendar", calendarView);
@@ -0,0 +1,91 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { formatDate } from "@web/core/l10n/dates";
const { Component } = owl;
export class CalendarYearPopover extends Component {
get recordGroups() {
return this.computeRecordGroups();
}
get dialogTitle() {
return formatDate(this.props.date, { format: "DDD" });
}
computeRecordGroups() {
const recordGroups = this.groupRecords();
return this.getSortedRecordGroups(recordGroups);
}
groupRecords() {
const recordGroups = {};
for (const record of this.props.records) {
const start = record.start;
const end = record.end;
const duration = end.diff(start, "days").days;
const modifiedRecord = Object.create(record);
modifiedRecord.startHour =
!record.isAllDay && duration < 1 ? start.toFormat("HH:mm") : "";
const formattedDate = this.getFormattedDate(start, end);
if (!(formattedDate in recordGroups)) {
recordGroups[formattedDate] = {
title: formattedDate,
start,
end,
records: [],
};
}
recordGroups[formattedDate].records.push(modifiedRecord);
}
return Object.values(recordGroups);
}
getSortedRecordGroups(recordGroups) {
return recordGroups.sort((a, b) => {
if (a.start.hasSame(a.end, "days")) {
return Number.MIN_SAFE_INTEGER;
} else if (b.start.hasSame(b.end, "days")) {
return Number.MAX_SAFE_INTEGER;
} else if (a.start.toMillis() - b.start.toMillis() === 0) {
return a.end.toMillis() - b.end.toMillis();
}
return a.start.toMillis() - b.start.toMillis();
});
}
getFormattedDate(start, end) {
const isSameDay = start.hasSame(end, "days");
if (!isSameDay && start.hasSame(end, "month")) {
// Simplify date-range if an event occurs into the same month (eg. "August 4-5, 2019")
return start.toFormat("LLLL d") + "-" + end.toFormat("d, y");
} else {
return isSameDay
? start.toFormat("DDD")
: start.toFormat("DDD") + " - " + end.toFormat("DDD");
}
}
onCreateButtonClick() {
this.props.createRecord({
start: this.props.date,
isAllDay: true,
});
this.props.close();
}
onRecordClick(record) {
this.props.editRecord(record);
this.props.close();
}
}
CalendarYearPopover.components = { Dialog };
CalendarYearPopover.template = "web.CalendarYearPopover";
CalendarYearPopover.props = {
close: Function,
date: true,
model: Object,
records: Array,
createRecord: Function,
deleteRecord: Function,
editRecord: Function,
};
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarYearPopover" owl="1">
<t t-if="env.isSmall">
<Dialog title="dialogTitle">
<t t-call="web.CalendarYearPopover.body" />
<t t-set-slot="footer">
<t t-call="web.CalendarYearPopover.footer" />
</t>
</Dialog>
</t>
<t t-else="">
<t t-call="web.CalendarYearPopover.popover" />
</t>
</t>
<t t-name="web.CalendarYearPopover.popover" owl="1">
<div class="position-absolute" style="top: 0; right: 0.5rem;">
<span class="o_cw_popover_close" t-on-click.stop="() => props.close()">
<i class="fa fa-close small" />
</span>
</div>
<div class="o_cw_body">
<t t-call="web.CalendarYearPopover.body" />
</div>
<div t-if="props.model.canCreate" class="mt-2">
<t t-call="web.CalendarYearPopover.footer" />
</div>
</t>
<t t-name="web.CalendarYearPopover.body" owl="1">
<t t-foreach="recordGroups" t-as="recordGroup" t-key="recordGroup.title">
<div class="fw-bold mt-2" t-esc="recordGroup.title" />
<t t-foreach="recordGroup.records" t-as="record" t-key="record.id">
<t t-call="web.CalendarYearPopover.record" />
</t>
</t>
</t>
<t t-name="web.CalendarYearPopover.footer" owl="1">
<t t-if="props.model.canCreate">
<a href="#" class="btn-link o_cw_popover_create" t-on-click.prevent="onCreateButtonClick">
<i class="fa fa-plus" />
Create
</a>
</t>
<t t-else="">
<button class="btn btn-primary o-default-button" t-on-click="props.close">
<t>Ok</t>
</button>
</t>
</t>
<t t-name="web.CalendarYearPopover.record" owl="1">
<a
href="#"
t-on-click.prevent="() => this.onRecordClick(record)"
t-attf-class="o_cw_popover_link btn-link d-block"
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"/>
</a>
</t>
</templates>
@@ -0,0 +1,192 @@
/** @odoo-module **/
import { formatDate } from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
import { useDebounced } from "@web/core/utils/timing";
import { getColor } from "../colors";
import { useCalendarPopover, useFullCalendar } from "../hooks";
import { CalendarYearPopover } from "./calendar_year_popover";
const { Component, useEffect, useRef, onRendered } = owl;
export class CalendarYearRenderer extends Component {
setup() {
this.months = luxon.Info.months();
this.fcs = {};
for (const month of this.months) {
this.fcs[month] = useFullCalendar(
`fullCalendar-${month}`,
this.getOptionsForMonth(month)
);
}
this.popover = useCalendarPopover(this.constructor.components.Popover);
this.rootRef = useRef("root");
this.onWindowResizeDebounced = useDebounced(this.onWindowResize, 200);
useEffect(() => {
this.updateSize();
});
onRendered(() => {
const year = formatDate(this.props.model.date, { format: "yyyy" });
this.env.config.setDisplayName(`${this.props.displayName} (${year})`);
});
}
get options() {
return {
columnHeaderFormat: (info) =>
luxon.DateTime.fromJSDate(info.date.marker).toFormat("EEEEE"),
contentHeight: 0,
dateClick: this.onDateClick,
dayRender: this.onDayRender,
defaultDate: this.props.model.date.toISO(),
defaultView: "dayGridMonth",
dir: localization.direction,
droppable: true,
editable: this.props.model.canEdit,
eventLimit: this.props.model.eventLimit,
eventRender: this.onEventRender,
eventResizableFromStart: true,
events: (_, successCb) => successCb(this.mapRecordsToEvents()),
firstDay: this.props.model.firstDayOfWeek,
header: { left: false, center: "title", right: false },
height: 0,
locale: luxon.Settings.defaultLocale,
longPressDelay: 500,
navLinks: false,
nowIndicator: true,
plugins: ["dayGrid", "interaction", "luxon"],
select: this.onSelect,
selectMinDistance: 5, // needed to not trigger select when click
selectMirror: true,
selectable: this.props.model.canCreate,
showNonCurrentDates: false,
timeZone: luxon.Settings.defaultZone.name,
titleFormat: { month: "short", year: "numeric" },
unselectAuto: false,
weekNumberCalculation: "ISO",
weekNumbers: false,
windowResize: this.onWindowResizeDebounced,
};
}
mapRecordsToEvents() {
return Object.values(this.props.model.records).map((r) => this.convertRecordToEvent(r));
}
convertRecordToEvent(record) {
return {
id: record.id,
title: record.title,
start: record.start.toISO(),
end: record.end.plus({ day: 1 }).toISO(),
allDay: true,
rendering: "background",
};
}
getDateWithMonth(month) {
return this.props.model.date.set({ month: this.months.indexOf(month) + 1 }).toISO();
}
getOptionsForMonth(month) {
return {
...this.options,
defaultDate: this.getDateWithMonth(month),
};
}
getPopoverProps(date, records) {
return {
date,
records,
model: this.props.model,
createRecord: this.props.createRecord,
deleteRecord: this.props.deleteRecord,
editRecord: this.props.editRecord,
};
}
openPopover(target, date, records) {
this.popover.open(target, this.getPopoverProps(date, records));
}
unselect() {
for (const fc of Object.values(this.fcs)) {
fc.api.unselect();
}
}
updateSize() {
const height = window.innerHeight - this.rootRef.el.getBoundingClientRect().top;
this.rootRef.el.style.height = `${height}px`;
}
onDateClick(info) {
if (this.env.isSmall) {
this.props.model.load({
date: luxon.DateTime.fromISO(info.dateStr),
scale: "day",
});
return;
}
// With date value we don't want to change the time, we need the exact date
const date = luxon.DateTime.fromISO(info.dateStr);
const records = Object.values(this.props.model.records).filter((r) =>
luxon.Interval.fromDateTimes(r.start.startOf("day"), r.end.endOf("day")).contains(date)
);
this.popover.close();
if (records.length) {
const target = info.dayEl;
this.openPopover(target, date, records);
} else if (this.props.model.canCreate) {
this.props.createRecord({
// With date value we don't want to change the time, we need the exact date
start: luxon.DateTime.fromISO(info.dateStr),
isAllDay: true,
});
}
}
onDayRender(info) {
const date = luxon.DateTime.fromJSDate(info.date).toISODate();
if (this.props.model.unusualDays.includes(date)) {
info.el.classList.add("o_calendar_disabled");
}
}
onEventRender(info) {
const { el, event } = info;
el.dataset.eventId = event.id;
el.classList.add("o_event");
const record = this.props.model.records[event.id];
if (record) {
const color = getColor(record.colorIndex);
if (typeof color === "string") {
el.style.backgroundColor = color;
} else if (typeof color === "number") {
el.classList.add(`o_calendar_color_${color}`);
} else {
el.classList.add("o_calendar_color_1");
}
if (record.isHatched) {
el.classList.add("o_event_hatched");
}
if (record.isStriked) {
el.classList.add("o_event_striked");
}
}
}
async onSelect(info) {
this.popover.close();
await this.props.createRecord({
// With date value we don't want to change the time, we need the exact date
start: luxon.DateTime.fromISO(info.startStr),
end: luxon.DateTime.fromISO(info.endStr).minus({ days: 1 }),
isAllDay: true,
});
this.unselect();
}
onWindowResize() {
this.updateSize();
}
}
CalendarYearRenderer.components = {
Popover: CalendarYearPopover,
};
CalendarYearRenderer.template = "web.CalendarYearRenderer";
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarYearRenderer" owl="1">
<div class="o_calendar_widget" t-ref="root">
<div class="fc-view-container">
<div class="fc fc-dayGridYear-view">
<t t-foreach="months" t-as="month" t-key="month">
<div class="fc-month-container">
<div class="fc-month" t-ref="fullCalendar-{{ month }}" />
</div>
</t>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,24 @@
/** @odoo-module **/
const CSS_COLOR_REGEX = /^((#[A-F0-9]{3})|(#[A-F0-9]{6})|((hsl|rgb)a?\(\s*(?:(\s*\d{1,3}%?\s*),?){3}(\s*,[0-9.]{1,4})?\))|)$/i;
const colorMap = new Map();
export function getColor(key) {
if (!key) {
return false;
}
if (colorMap.has(key)) {
return colorMap.get(key);
}
// check if the key is a css color
if (typeof key === "string" && key.match(CSS_COLOR_REGEX)) {
colorMap.set(key, key);
} else if (typeof key === "number") {
colorMap.set(key, key);
} else {
colorMap.set(key, (((colorMap.size + 1) * 5) % 24) + 1);
}
return colorMap.get(key);
}
@@ -0,0 +1,101 @@
/** @odoo-module **/
const { Component, onMounted, onWillUnmount, useEffect, useRef, xml } = owl;
const { DateTime, Info } = luxon;
export class CalendarDatePicker extends Component {
setup() {
this.rootRef = useRef("root");
onMounted(() => {
this.$el.datepicker(this.options);
});
useEffect(() => {
this.highlight();
});
onWillUnmount(() => {
this.$el.datepicker("destroy");
const picker = document.querySelector("#ui-datepicker-div:empty");
if (picker) {
picker.remove();
}
});
}
get options() {
// this is needed because luxon gives the week in ISO format : Monday is the first day of the week.
// (M T W T F S S) but the jsquery datepicker wants as day name option in US format (S M T W T F S)
const weekdays = Array.from(Info.weekdays("narrow"));
const last = weekdays.pop();
return {
dayNamesMin: [last, ...weekdays],
firstDay: (this.props.model.firstDayOfWeek || 0) % 7,
monthNames: Info.months("short"),
onSelect: this.onDateSelected.bind(this),
showOtherMonths: true,
dateFormat: "yy-mm-dd",
};
}
get $el() {
return $(this.rootRef.el);
}
highlight() {
this.$el
.datepicker("setDate", this.props.model.date.toFormat("yyyy-MM-dd"))
.find(".o_selected_range")
.removeClass("o_color o_selected_range");
let $a;
switch (this.props.model.scale) {
case "year":
$a = this.$el.find("td");
break;
case "month":
$a = this.$el.find("td");
break;
case "week":
$a = this.$el.find("tr:has(.ui-state-active)");
break;
case "day":
$a = this.$el.find("a.ui-state-active");
break;
}
$a.addClass("o_selected_range");
$a.not(".ui-state-active").addClass("o_color");
// Correctly highlight today
// This is needed in case the user's local timezone is different from the system one
const { year, month, day } = DateTime.local();
this.$el.find(".ui-datepicker-today").removeClass("ui-datepicker-today");
this.$el
.find(`td[data-year="${year}"][data-month="${month - 1}"]:contains("${day}")`)
.addClass("ui-datepicker-today");
}
onDateSelected(_, info) {
const model = this.props.model;
const date = DateTime.local(+info.currentYear, +info.currentMonth + 1, +info.currentDay);
let scale = "week";
if (model.date.hasSame(date, "day")) {
const scales = ["month", "week", "day"];
scale = scales[(scales.indexOf(model.scale) + 1) % scales.length];
} else {
// Check if dates are on the same week
// As a.hasSame(b, "week") does not depend on locale and week always starts on Monday,
// we are comparing derivated dates instead to take this into account.
const currentDate = model.date.weekday === 7 ? model.date.plus({ day: 1 }) : model.date;
const pickedDate = date.weekday === 7 ? date.plus({ day: 1 }) : date;
// a.hasSame(b, "week") does not depend on locale and week alway starts on Monday
if (currentDate.hasSame(pickedDate, "week")) {
scale = "day";
}
}
model.load({ scale, date });
}
}
CalendarDatePicker.props = {
model: Object,
};
CalendarDatePicker.template = xml`<div class="o_calendar_mini" t-ref="root" />`;
@@ -0,0 +1,226 @@
/** @odoo-module **/
import { usePopover } from "@web/core/popover/popover_hook";
import { _t } from "@web/core/l10n/translation";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { Transition } from "@web/core/transition";
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
const { Component, useState } = owl;
class FilterAutoComplete extends Component {
setup() {
this.orm = useService("orm");
this.addDialog = useOwnedDialogs();
this.state = useState({
value: "",
});
this.sources = [{ placeholder: _t("Loading..."), options: this.loadSource.bind(this) }];
}
async loadSource(request) {
const records = await this.orm.call(this.props.resModel, "name_search", [], {
name: request,
operator: "ilike",
args: this.props.domain,
limit: 8,
context: {},
});
const options = records.map((result) => ({
value: result[0],
label: result[1],
}));
if (records.length > 7) {
options.push({
label: _t("Search More..."),
action: this.onSearchMore.bind(this, request),
});
}
return options;
}
async onSearchMore(request) {
let dynamicFilters = [];
if (request.length) {
const nameGets = await this.orm.call(this.props.resModel, "name_search", [], {
name: request,
args: this.props.domain,
operator: "ilike",
context: {},
});
dynamicFilters = [
{
description: sprintf(_t("Quick search: %s"), request),
domain: [["id", "in", nameGets.map((nameGet) => nameGet[0])]],
},
];
}
const title = sprintf(this.env._t("Search: %s"), this.props.searchMoreTitle);
this.addDialog(SelectCreateDialog, {
title: title || _t("Select records"),
noCreate: true,
multiSelect: false,
resModel: this.props.resModel,
context: {},
domain: this.props.domain,
onSelected: (...args) => {
console.log(...args);
},
dynamicFilters,
});
}
onInput({ inputValue }) {
this.state.value = inputValue;
}
onSelect(option, params = {}) {
this.props.onSelect(option.value);
this.state.value = "";
}
}
FilterAutoComplete.template = "web.FilterAutoComplete";
FilterAutoComplete.components = {
AutoComplete,
};
class CalendarFilterTooltip extends Component {}
CalendarFilterTooltip.template = "web.CalendarFilterPanel.tooltip";
let nextId = 1;
export class CalendarFilterPanel extends Component {
setup() {
this.state = useState({
collapsed: {},
fieldRev: 1,
});
this.popover = usePopover();
this.removePopover = null;
}
getAutoCompleteProps(section) {
return {
resModel: this.props.model.fields[section.fieldName].relation,
domain: [
[
"id",
"not in",
section.filters.filter((f) => f.type !== "all").map((f) => f.value),
],
],
searchMoreTitle: section.label,
placeholder: `+ ${_t("Add")} ${section.label}`,
onSelect: (value) => {
this.props.model.createFilter(section.fieldName, value);
},
};
}
get nextFilterId() {
nextId += 1;
return nextId;
}
isAllActive(section) {
let active = true;
for (const filter of section.filters) {
if (filter.type !== "all" && !filter.active) {
active = false;
break;
}
}
return active;
}
getFilterTypePriority(type) {
return ["user", "record", "dynamic", "all"].indexOf(type);
}
getSortedFilters(section) {
return section.filters.slice().sort((a, b) => {
if (a.type === b.type) {
const va = a.value ? -1 : 0;
const vb = b.value ? -1 : 0;
if (a.type === "dynamic" && va !== vb) {
return va - vb;
}
return b.label.localeCompare(a.label);
} else {
return this.getFilterTypePriority(a.type) - this.getFilterTypePriority(b.type);
}
});
}
toggleSection(section) {
if (section.canCollapse) {
this.state.collapsed[section.fieldName] = !this.state.collapsed[section.fieldName];
}
}
isSectionCollapsed(section) {
return this.state.collapsed[section.fieldName] || false;
}
closeTooltip() {
if (this.removePopover) {
this.removePopover();
this.removePopover = null;
}
}
onFilterInputChange(section, filter, ev) {
this.props.model.updateFilters(section.fieldName, {
[filter.value]: ev.target.checked,
});
}
onAllFilterInputChange(section, ev) {
const filters = {};
for (const filter of section.filters) {
if (filter.type !== "all") {
filters[filter.value] = ev.target.checked;
}
}
this.props.model.updateFilters(section.fieldName, filters);
}
onFilterMouseEnter(section, filter, ev) {
this.closeTooltip();
if (!section.hasAvatar || !filter.hasAvatar) {
return;
}
this.removePopover = this.popover.add(
ev.currentTarget,
CalendarFilterTooltip,
{ section, filter },
{
closeOnClickAway: false,
popoverClass: "o-calendar-filter--tooltip",
position: "top",
}
);
}
onFilterMouseLeave() {
this.closeTooltip();
}
onFilterRemoveBtnClick(section, filter) {
this.props.model.unlinkFilter(section.fieldName, filter.recordId);
}
onFieldChanged(fieldName, filterValue) {
this.state.fieldRev += 1;
this.props.model.createFilter(fieldName, filterValue);
}
}
CalendarFilterPanel.components = {
FilterAutoComplete,
Transition,
};
CalendarFilterPanel.template = "web.CalendarFilterPanel";
@@ -0,0 +1,13 @@
.o-section-slide {
max-height: 0;
transition: max-height 0.35s ease;
&.o-section-slide-enter,
&.o-section-slide-leave {
overflow: hidden !important;
}
&.o-section-slide-enter-active {
max-height: 20rem; // fixed value is required to properly trigger transition
overflow: auto;
}
}
@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarFilterPanel" owl="1">
<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"
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)"
/>
<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"
type="button"
t-on-click.stop.prevent="() => this.toggleSection(section, ev)"
>
<span class="o_cw_filter_label" t-esc="section.label" />
<i
class="o_cw_filter_collapse_icon fa"
t-attf-class="fa-chevron-{{ isSectionCollapsed(section) ? 'left' : 'down' }}"
/>
</div>
</t>
<t t-else="">
<h5 class="o_cw_filter_label" 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">
<t t-foreach="getSortedFilters(section)" t-as="filter" t-key="filter.value">
<t t-set="filterId" t-value="nextFilterId" />
<t t-call="web.CalendarFilterPanel.filter" />
</t>
</div>
</Transition>
<t t-if="section.canAddFilter">
<FilterAutoComplete t-props="getAutoCompleteProps(section)" />
</t>
</div>
</t>
</t>
</t>
<t t-name="web.CalendarFilterPanel.filter" owl="1">
<div
class="o_calendar_filter_item w-100 position-relative mb-2"
t-att-class="filter.colorIndex !== null ? 'o_cw_filter_color_' + filter.colorIndex : ''"
t-att-data-value="filter.value"
t-on-mouseenter="(ev) => this.onFilterMouseEnter(section, filter, ev)"
t-on-mouseleave="() => this.onFilterMouseLeave(section, filter)"
>
<input
type="checkbox"
name="selection"
class="position-absolute"
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"
t-attf-for="o_calendar_filter_item_{{filterId}}"
>
<span
class="o_cw_filter_input_bg align-items-start d-flex flex-shrink-0 justify-content-center position-relative me-1 o_beside_avatar"
t-att-style="filter.colorIndex and typeof filter.colorIndex !== 'number' ? `border-color: ${filter.colorIndex}; background-color: ${filter.colorIndex};` : ''"
>
<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"
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"
role="img"
aria-label="Avatar"
title="Avatar"
/>
</t>
<span
class="o_cw_filter_title text-truncate flex-grow"
t-esc="filter.label"
/>
</label>
<t t-if="filter.canRemove">
<button
class="o_remove btn bg-white text-700 py-0 px-2"
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" />
</button>
</t>
</div>
</t>
<t t-name="web.CalendarFilterPanel.tooltip" owl="1">
<div class="card">
<h6
class="text-center card-header"
t-esc="props.filter.label"
/>
<div class="card-body">
<img
t-attf-src="/web/image/{{ props.section.avatar.model }}/{{ props.filter.value }}/{{ props.section.avatar.field }}"
class="mx-auto"
/>
</div>
</div>
</t>
<t t-name="web.FilterAutoComplete" owl="1">
<AutoComplete value="state.value" sources="sources" autoSelect="true" placeholder="props.placeholder" onSelect.bind="onSelect" onInput.bind="onInput" />
</t>
</templates>
@@ -0,0 +1,118 @@
/** @odoo-module **/
import { loadCSS, loadJS } from "@web/core/assets";
import { browser } from "@web/core/browser/browser";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
const { onMounted, onPatched, onWillStart, onWillUnmount, useComponent, useRef } = owl;
export function useCalendarPopover(component) {
const owner = useComponent();
const popover = usePopover();
const dialog = useService("dialog");
let remove = null;
function close() {
if (remove) {
remove();
remove = null;
}
}
return {
close,
open(target, props, popoverClass) {
close();
if (owner.env.isSmall) {
remove = dialog.add(component, props, { onClose: () => (remove = null) });
} else {
remove = popover.add(target, component, props, {
popoverClass,
position: "right",
onClose: () => (remove = null),
});
}
},
};
}
export function useClickHandler(singleClickCb, doubleClickCb) {
const component = useComponent();
let clickTimeoutId = null;
return function handle(...args) {
if (clickTimeoutId) {
doubleClickCb.call(component, ...args);
browser.clearTimeout(clickTimeoutId);
clickTimeoutId = null;
} else {
clickTimeoutId = browser.setTimeout(() => {
singleClickCb.call(component, ...args);
clickTimeoutId = null;
}, 250);
}
};
}
export function useFullCalendar(refName, params) {
const component = useComponent();
const ref = useRef(refName);
let instance = null;
function boundParams() {
const newParams = {};
for (const key in params) {
const value = params[key];
newParams[key] = typeof value === "function" ? value.bind(component) : value;
}
return newParams;
}
async function loadJsFiles() {
const files = [
"/web/static/lib/fullcalendar/core/main.js",
"/web/static/lib/fullcalendar/interaction/main.js",
"/web/static/lib/fullcalendar/daygrid/main.js",
"/web/static/lib/fullcalendar/luxon/main.js",
"/web/static/lib/fullcalendar/timegrid/main.js",
"/web/static/lib/fullcalendar/list/main.js",
];
for (const file of files) {
await loadJS(file);
}
}
async function loadCssFiles() {
await Promise.all(
[
"/web/static/lib/fullcalendar/core/main.css",
"/web/static/lib/fullcalendar/daygrid/main.css",
"/web/static/lib/fullcalendar/timegrid/main.css",
"/web/static/lib/fullcalendar/list/main.css",
].map((file) => loadCSS(file))
);
}
onWillStart(() => Promise.all([loadJsFiles(), loadCssFiles()]));
onMounted(() => {
try {
instance = new FullCalendar.Calendar(ref.el, boundParams());
instance.render();
} catch (e) {
throw new Error(`Cannot instantiate FullCalendar\n${e.message}`);
}
});
onPatched(() => {
instance.refetchEvents();
});
onWillUnmount(() => {
instance.destroy();
});
return {
get api() {
return instance;
},
get el() {
return ref.el;
},
};
}
@@ -0,0 +1,28 @@
/** @odoo-module **/
const { Component } = owl;
export class CalendarMobileFilterPanel extends Component {
get caretDirection() {
return this.props.sideBarShown ? "down" : "left";
}
getFilterTypePriority(type) {
return ["user", "record", "dynamic", "all"].indexOf(type);
}
getSortedFilters(section) {
return section.filters.slice().sort((a, b) => {
if (a.type === b.type) {
const va = a.value ? -1 : 0;
const vb = b.value ? -1 : 0;
if (a.type === "dynamic" && va !== vb) {
return va - vb;
}
return b.label.localeCompare(a.label);
} else {
return this.getFilterTypePriority(a.type) - this.getFilterTypePriority(b.type);
}
});
}
}
CalendarMobileFilterPanel.components = {};
CalendarMobileFilterPanel.template = "web.CalendarMobileFilterPanel";
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarMobileFilterPanel" owl="1">
<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-attf-class="o_color_{{filter.colorIndex}}">⬤</span>
<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" />
</div>
</t>
</templates>
@@ -0,0 +1,86 @@
/** @odoo-module **/
import { useAutofocus, useService } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
import { _lt } from "@web/core/l10n/translation";
const { Component } = owl;
export class CalendarQuickCreate extends Component {
setup() {
this.titleRef = useAutofocus({ refName: "title" });
this.notification = useService("notification");
this.creatingRecord = false;
}
get dialogTitle() {
return _lt("New Event");
}
get recordTitle() {
return this.titleRef.el.value.trim();
}
get record() {
return {
...this.props.record,
title: this.recordTitle,
};
}
editRecord() {
this.props.editRecord(this.record);
this.props.close();
}
async createRecord() {
if (this.creatingRecord) {
return;
}
if (this.recordTitle) {
try {
this.creatingRecord = true;
await this.props.model.createRecord(this.record);
this.props.close();
} catch {
this.editRecord();
}
} else {
this.titleRef.el.classList.add("o_field_invalid");
this.notification.add(this.env._t("Meeting Subject"), {
title: this.env._t("Invalid fields"),
type: "danger",
});
}
}
onInputKeyup(ev) {
switch (ev.key) {
case "Enter":
this.createRecord();
break;
case "Escape":
this.props.close();
break;
}
}
onCreateBtnClick() {
this.createRecord();
}
onEditBtnClick() {
this.editRecord();
}
onCancelBtnClick() {
this.props.close();
}
}
CalendarQuickCreate.template = "web.CalendarQuickCreate";
CalendarQuickCreate.components = {
Dialog,
};
CalendarQuickCreate.props = {
close: Function,
record: Object,
model: Object,
editRecord: Function,
};
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CalendarQuickCreate" owl="1">
<Dialog size="'sm'" title="dialogTitle">
<div class="o-calendar-quick-create mb-3">
<label for="title" class="col-form-label o_form_label">
Meeting Subject:
</label>
<input
type="text"
class="o_input o_required_modifier o-calendar-quick-create--input"
name="title"
t-ref="title"
t-on-keyup="onInputKeyup"
/>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary o-calendar-quick-create--create-btn" t-on-click="onCreateBtnClick">
Create
</button>
<button class="btn btn-secondary o-calendar-quick-create--edit-btn" t-on-click="onEditBtnClick">
Edit
</button>
<button class="btn btn-secondary o-calendar-quick-create--cancel-btn" t-on-click="onCancelBtnClick">
Cancel
</button>
</t>
</Dialog>
</t>
</templates>
@@ -354,3 +354,6 @@ Many2ManyTagsFieldColorEditable.extractProps = (params) => {
};
registry.category("fields").add("form.many2many_tags", Many2ManyTagsFieldColorEditable);
registry.category("fields").add("calendar.one2many", Many2ManyTagsField);
registry.category("fields").add("calendar.many2many", Many2ManyTagsField);
@@ -41,6 +41,7 @@
t-att-id="props.id"
class="o_input"
readonly=""
placeholder="props.placeholder"
t-att-value="props.value"
t-on-click="onSearchMoreSmall"
/>
+15 -4
View File
@@ -7,9 +7,11 @@ const { Component, xml, onWillStart, onWillUpdateProps } = owl;
class _Record extends Component {
setup() {
const activeFields = Object.fromEntries(
this.props.info.fieldNames.map((f) => [f, { attrs: {}, options: {}, domain: "[]" }])
);
const activeFields =
this.props.info.activeFields ||
Object.fromEntries(
this.props.info.fieldNames.map((f) => [f, { attrs: {}, options: {}, domain: "[]" }])
);
this.model = useModel(RelationalModel, {
resId: this.props.info.resId,
@@ -50,4 +52,13 @@ export class Record extends Component {
}
Record.template = xml`<_Record fields="fields" slots="props.slots" info="props" />`;
Record.components = { _Record };
Record.props = ["slots", "resModel", "fieldNames", "fields?", "resId?", "mode?", "initialValues?"];
Record.props = [
"slots",
"resModel",
"fieldNames?",
"activeFields?",
"fields?",
"resId?",
"mode?",
"initialValues?",
];
@@ -814,7 +814,7 @@ export class Record extends DataPoint {
const changes = params.changes || (await this._onChange());
await this._load({ changes });
} else {
let values = params.values || {};
let values = this._parseServerValues(params.values);
const missingFields = this.fieldNames.filter((fieldName) => !(fieldName in values));
if (missingFields.length) {
values = Object.assign({}, values, await this._read(missingFields));
@@ -2034,7 +2034,6 @@ export class DynamicRecordList extends DynamicList {
const records = await Promise.all(
rawRecords.map(async (data) => {
data = this._parseServerValues(data);
const record = this.model.createDataPoint("record", {
resModel: this.resModel,
resId: data.id,
@@ -3230,7 +3229,7 @@ export class StaticList extends DataPoint {
if (!this._initialValues) {
this._initialValues = {};
}
this._initialValues[id] = this._parseServerValues(command[2]);
this._initialValues[id] = command[2];
}
command[2] = symbolValues;
}
+6 -3
View File
@@ -12,7 +12,7 @@ import { WithSearch } from "@web/search/with_search/with_search";
import { OnboardingBanner } from "@web/views/onboarding_banner";
import { useActionLinks } from "@web/views/view_hook";
const { Component, markRaw, onWillUpdateProps, onWillStart, toRaw, useSubEnv } = owl;
const { Component, markRaw, onWillUpdateProps, onWillStart, toRaw, useSubEnv, reactive } = owl;
const viewRegistry = registry.category("views");
/** @typedef {Object} Config
@@ -41,18 +41,21 @@ export function getDefaultConfig() {
actionId: false,
actionType: false,
actionFlags: {},
breadcrumbs: [
breadcrumbs: reactive([
{
get name() {
return displayName;
},
},
],
]),
getDisplayName: () => displayName,
historyBack: () => {},
pagerProps: {},
setDisplayName: (newDisplayName) => {
displayName = newDisplayName;
// This is a hack to force the reactivity when a new displayName is set
config.breadcrumbs.push(undefined);
config.breadcrumbs.pop();
},
viewSwitcherEntries: [],
views: [],
@@ -15,7 +15,7 @@ import { ActionDialog } from "./action_dialog";
import { CallbackRecorder } from "./action_hook";
import { ReportAction } from "./reports/report_action";
const { Component, markup, onMounted, onWillUnmount, onError, useChildSubEnv, xml } = owl;
const { Component, markup, onMounted, onWillUnmount, onError, useChildSubEnv, xml, reactive } = owl;
const actionHandlersRegistry = registry.category("action_handlers");
const actionRegistry = registry.category("actions");
@@ -575,7 +575,9 @@ function makeActionManager(env) {
}
const nextStack = controllerStack.slice(0, index).concat(controllerArray);
// Compute breadcrumbs
controller.config.breadcrumbs = action.target === "new" ? [] : _getBreadcrumbs(nextStack);
controller.config.breadcrumbs = reactive(
action.target === "new" ? [] : _getBreadcrumbs(nextStack)
);
controller.config.getDisplayName = () => controller.displayName;
controller.config.setDisplayName = (displayName) => {
controller.displayName = displayName;
@@ -583,6 +585,11 @@ function makeActionManager(env) {
// if not mounted yet, will be done in "mounted"
env.services.title.setParts({ action: controller.displayName });
}
if (action.target !== "new") {
// This is a hack to force the reactivity when a new displayName is set
controller.config.breadcrumbs.push(undefined);
controller.config.breadcrumbs.pop();
}
};
controller.config.historyBack = () => {
const previousController = controllerStack[controllerStack.length - 2];
+1 -1
View File
@@ -157,7 +157,7 @@ export function makeDeferred() {
return new Deferred();
}
function findElement(el, selector) {
export function findElement(el, selector) {
let target = el;
if (selector) {
const els = el.querySelectorAll(selector);
@@ -3,6 +3,7 @@ odoo.define('web.owl_dialog_tests', function (require) {
const LegacyDialog = require('web.Dialog');
const FormView = require('web.FormView');
const CalendarView = require('web.CalendarView');
const makeTestEnvironment = require('web.test_env');
const Dialog = require('web.OwlDialog');
const testUtils = require('web.test_utils');
@@ -418,8 +419,10 @@ odoo.define('web.owl_dialog_tests', function (require) {
});
QUnit.test("remove tabindex on inactive dialog", async (assert) => {
registry.category("views").remove("calendar"); // remove new calendar from registry
registry.category("views").remove("form"); // remove new form from registry
legacyViewRegistry.add("form", FormView); // add legacy form -> will be wrapped and added to new registry
legacyViewRegistry.add("calendar", CalendarView); // add legacy calendar -> will be wrapped and added to new registry
const serverData = {
actions: {
@@ -3,6 +3,7 @@ odoo.define('web.calendar_tests', function (require) {
const AbstractField = require('web.AbstractField');
const BasicModel = require('web.BasicModel');
const FormView = require('web.FormView');
var CalendarView = require('web.CalendarView');
var CalendarRenderer = require('web.CalendarRenderer');
var Dialog = require('web.Dialog');
@@ -13,6 +14,8 @@ var mixins = require('web.mixins');
var testUtils = require('web.test_utils');
var session = require('web.session');
const Widget = require('web.Widget');
const { registry } = require('@web/core/registry');
const legacyViewRegistry = require('web.view_registry');
const { getFixture, patchWithCleanup } = require("@web/../tests/helpers/utils");
@@ -46,6 +49,10 @@ QUnit.module('LegacyViews', {
patchWithCleanup(session, {
uid: -1
});
registry.category("views").remove("calendar"); // remove new calendar from registry
registry.category("views").remove("form"); // remove new form from registry
legacyViewRegistry.add("calendar", CalendarView); // add legacy calendar -> will be wrapped and added to new registry
legacyViewRegistry.add("form", FormView); // add legacy form -> will be wrapped and added to new registry
this.data = {
event: {
fields: {
@@ -201,7 +208,7 @@ QUnit.module('LegacyViews', {
},
});
assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length,
assert.ok(calendar.$('.o_legacy_calendar_view').find('.fc-view-container').length,
"should instance of fullcalendar");
var $sidebar = calendar.$('.o_calendar_sidebar');
@@ -3762,7 +3769,7 @@ QUnit.module('LegacyViews', {
initialDate: initDate,
},
});
assert.ok(calendar.$('.o_calendar_view').find('.fc-view-container').length, "should display in the calendar"); // OK
assert.ok(calendar.$('.o_legacy_calendar_view').find('.fc-view-container').length, "should display in the calendar"); // OK
// Testing the order of the events: by start date
assert.strictEqual(calendar.$('.o_event_title').length, 3, "3 events should be available"); // OK
assert.strictEqual(calendar.$('.o_event_title').first().text(), 'First event', "First event should be on top");
+66
View File
@@ -56,3 +56,69 @@ export async function swipeRight(target, selector) {
export async function swipeLeft(target, selector) {
return swipe(target, selector, "left");
}
/**
* Simulate a "TAP" (touch) on the target element with the given selector.
*
* @param {HTMLElement} target
* @param {DOMSelector} [selector]
* @returns {Promise}
*/
export async function tap(target, selector) {
const touchTarget = findElement(target, selector);
const box = touchTarget.getBoundingClientRect();
const x = box.left + box.width / 2;
const y = box.top + box.height / 2;
const touch = {
identifier: 0,
target: touchTarget,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
};
await triggerEvent(touchTarget, null, "touchstart", {
touches: [touch],
});
await triggerEvent(touchTarget, null, "touchend", {});
}
/**
* Simulate a "TAP" (touch) on the target element with the given selector.
*
* @param {HTMLElement} target
* @param {DOMSelector} startSelector
* @param {DOMSelector} endSelector
* @param {{start: "center"|"top", end: "center"|"bottom"}} [positions]
* Specify where the touches will occur in the start and end elements.
* @returns {Promise}
*/
export async function tapAndMove(
target,
startSelector,
endSelector,
positions = { start: "center", end: "center" }
) {
const startTarget = findElement(target, startSelector);
const startBox = startTarget.getBoundingClientRect();
const touch = {
identifier: 0,
target: startTarget,
pageX: startBox.x + startBox.width / 2,
pageY: positions.start === "center" ? startBox.y + startBox.height / 2 : startBox.y + 1,
};
await triggerEvent(startTarget, null, "touchstart", {
touches: [touch],
});
const endTarget = findElement(target, endSelector);
const endBox = endTarget.getBoundingClientRect();
touch.pageX = endBox.x + endBox.width / 2;
touch.pageY = positions.end === "center" ? endBox.y + endBox.height / 2 : endBox.y - 1;
await triggerEvent(startTarget, null, "touchmove", {
touches: [touch],
});
await triggerEvent(startTarget, null, "touchend", {
touches: [touch],
});
}
@@ -0,0 +1,396 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { CalendarCommonRenderer } from "@web/views/calendar/calendar_common/calendar_common_renderer";
import { CalendarYearRenderer } from "@web/views/calendar/calendar_year/calendar_year_renderer";
import { click, getFixture, nextTick, patchDate, patchWithCleanup } from "../../helpers/utils";
import { changeScale, clickEvent, toggleSectionFilter } from "../../views/calendar/helpers";
import { makeView, setupViewRegistries } from "../../views/helpers";
import { tap, swipeRight, tapAndMove } from "../helpers";
let target;
let serverData;
QUnit.module("Views", ({ beforeEach }) => {
beforeEach(() => {
// 2016-12-12 08:00:00
patchDate(2016, 11, 12, 8, 0, 0);
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
target = getFixture();
setupViewRegistries();
serverData = {
models: {
event: {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "name", type: "char" },
start: { string: "start datetime", type: "datetime" },
stop: { string: "stop datetime", type: "datetime" },
partner_id: {
string: "user",
type: "many2one",
relation: "partner",
related: "user_id.partner_id",
default: 1,
},
},
records: [
{
id: 1,
partner_id: 1,
name: "event 1",
start: "2016-12-11 00:00:00",
stop: "2016-12-11 00:00:00",
},
{
id: 2,
partner_id: 2,
name: "event 2",
start: "2016-12-12 10:55:05",
stop: "2016-12-12 14:55:05",
},
],
methods: {
check_access_rights() {
return Promise.resolve(true);
},
},
},
partner: {
fields: {
id: { string: "ID", type: "integer" },
image: { string: "Image", type: "binary" },
display_name: { string: "Displayed name", type: "char" },
},
records: [
{ id: 1, display_name: "partner 1", image: "AAA" },
{ id: 2, display_name: "partner 2", image: "BBB" },
],
},
},
};
});
QUnit.module("CalendarView - Mobile");
QUnit.test("simple calendar rendering in mobile", async function (assert) {
await makeView({
type: "calendar",
resModel: "event",
arch: `
<calendar date_start="start" date_stop="stop">
<field name="name"/>
</calendar>`,
serverData,
});
assert.containsNone(target, ".o_calendar_button_prev", "prev button should be hidden");
assert.containsNone(target, ".o_calendar_button_next", "next button should be hidden");
assert.isVisible(
target.querySelector(
".o_cp_bottom_left .o_calendar_buttons .o_calendar_scale_buttons + button.o_cp_today_button"
),
"today button should be visible near the calendar buttons (bottom left corner)"
);
// Test all views
// displays month mode by default
assert.containsOnce(
target,
".fc-view-container > .fc-timeGridWeek-view",
"should display the current week"
);
assert.equal(
target.querySelector(".breadcrumb-item").textContent,
"undefined (Dec 11 – 17, 2016)"
);
// switch to day mode
await click(target, ".o_control_panel .scale_button_selection");
await click(target, ".o_control_panel .o_calendar_button_day");
await nextTick();
assert.containsOnce(
target,
".fc-view-container > .fc-timeGridDay-view",
"should display the current day"
);
assert.equal(
target.querySelector(".breadcrumb-item").textContent,
"undefined (December 12, 2016)"
);
// switch to month mode
await click(target, ".o_control_panel .scale_button_selection");
await click(target, ".o_control_panel .o_calendar_button_month");
await nextTick();
assert.containsOnce(
target,
".fc-view-container > .fc-dayGridMonth-view",
"should display the current month"
);
assert.equal(
target.querySelector(".breadcrumb-item").textContent,
"undefined (December 2016)"
);
// switch to year mode
await click(target, ".o_control_panel .scale_button_selection");
await click(target, ".o_control_panel .o_calendar_button_year");
await nextTick();
assert.containsOnce(
target,
".fc-view-container > .fc-dayGridYear-view",
"should display the current year"
);
assert.equal(target.querySelector(".breadcrumb-item").textContent, "undefined (2016)");
});
QUnit.test("calendar: popover is rendered as dialog in mobile", async function (assert) {
// Legacy name of this test: "calendar: popover rendering in mobile"
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `
<calendar date_start="start" date_stop="stop">
<field name="name"/>
</calendar>`,
});
await clickEvent(target, 1);
assert.containsNone(target, ".o_cw_popover");
assert.containsOnce(target, ".modal");
assert.hasClass(target.querySelector(".modal"), "o_modal_full");
assert.containsN(target, ".modal-footer .btn", 2);
assert.containsOnce(target, ".modal-footer .btn.btn-primary.o_cw_popover_edit");
assert.containsOnce(target, ".modal-footer .btn.btn-secondary.o_cw_popover_delete");
});
QUnit.test("calendar: today button", async function (assert) {
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `<calendar mode="day" date_start="start" date_stop="stop"></calendar>`,
});
assert.equal(target.querySelector(".fc-day-header[data-date]").dataset.date, "2016-12-12");
// Swipe right
await swipeRight(target, ".o_calendar_widget");
assert.equal(target.querySelector(".fc-day-header[data-date]").dataset.date, "2016-12-11");
await click(target, ".o_calendar_button_today");
assert.equal(target.querySelector(".fc-day-header[data-date]").dataset.date, "2016-12-12");
});
QUnit.test("calendar: show and change other calendar", async function (assert) {
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `
<calendar date_start="start" date_stop="stop" color="partner_id">
<filter name="user_id" avatar_field="image"/>
<field name="partner_id" filters="1" invisible="1"/>
</calendar>`,
});
assert.containsOnce(target, ".o_other_calendar_panel");
assert.containsN(
target,
".o_other_calendar_panel .o_filter > *",
3,
"should contains 3 child nodes -> 1 label (USER) + 2 resources (user 1/2)"
);
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");
assert.containsOnce(target, ".o_calendar_filter[data-name=partner_id]");
// Toggle the whole section filters by unchecking the all items checkbox
await toggleSectionFilter(target, "partner_id");
assert.containsN(
target,
".o_other_calendar_panel .o_filter > *",
1,
"should contains 1 child node -> 1 label (USER)"
);
// Toggle again the other calendar panel should hide the sidebar and show the calendar view
await click(target, ".o_other_calendar_panel");
assert.containsNone(target, ".o_calendar_sidebar");
assert.containsOnce(target, ".o_calendar_renderer");
});
QUnit.test('calendar: tap on "Free Zone" opens quick create', async function (assert) {
patchWithCleanup(CalendarCommonRenderer.prototype, {
onDateClick(...args) {
assert.step("dateClick");
return this._super(...args);
},
onSelect(...args) {
assert.step("select");
return this._super(...args);
},
});
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `<calendar mode="day" date_start="start" date_stop="stop"/>`,
});
// Simulate a "TAP" (touch)
await tap(
target,
".fc-time-grid .fc-minor[data-time='00:30:00'] .fc-widget-content:last-child"
);
await nextTick();
// should open a Quick create modal view in mobile on short tap
assert.containsOnce(target, ".modal");
assert.verifySteps(["dateClick"]);
});
QUnit.test('calendar: select range on "Free Zone" opens quick create', async function (assert) {
patchWithCleanup(CalendarCommonRenderer.prototype, {
get options() {
return Object.assign({}, this._super(), {
selectLongPressDelay: 0,
});
},
onDateClick(info) {
assert.step("dateClick");
return this._super(info);
},
onSelect(info) {
assert.step("select");
const { startStr, endStr } = info;
assert.equal(startStr, "2016-12-12T01:00:00+01:00");
assert.equal(endStr, "2016-12-12T02:00:00+01:00");
return this._super(info);
},
});
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `<calendar mode="day" date_start="start" date_stop="stop"/>`,
});
// Simulate a "TAP" (touch)
await tapAndMove(
target,
".fc-time-grid [data-time='01:00:00'] .fc-widget-content:last-child",
".fc-time-grid [data-time='02:00:00'] .fc-widget-content:last-child",
{ start: "top", end: "bottom" }
);
await nextTick();
// should open a Quick create modal view in mobile on short tap
assert.containsOnce(target, ".modal");
assert.verifySteps(["select"]);
});
QUnit.test("calendar (year): select date range opens quick create", async function (assert) {
patchWithCleanup(CalendarYearRenderer.prototype, {
get options() {
return Object.assign({}, this._super(), {
longPressDelay: 0,
selectLongPressDelay: 0,
});
},
onDateClick(info) {
assert.step("dateClick");
return this._super(info);
},
onSelect(info) {
assert.step("select");
const { startStr, endStr } = info;
assert.equal(startStr, "2016-02-02");
assert.equal(endStr, "2016-02-06"); // end date is exclusive
return this._super(info);
},
});
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `<calendar mode="year" date_start="start" date_stop="stop"/>`,
});
// Tap on a date
await tapAndMove(
target,
".fc-day-top[data-date='2016-02-02']",
".fc-day-top[data-date='2016-02-05']"
);
await nextTick();
// should open a Quick create modal view in mobile on short tap
assert.containsOnce(target, ".modal");
assert.verifySteps(["select"]);
});
QUnit.test("calendar (month/year): tap on date switch to day scale", async function (assert) {
await makeView({
type: "calendar",
resModel: "event",
serverData,
arch: `<calendar mode="year" date_start="start" date_stop="stop"/>`,
});
// Should display year view
assert.containsOnce(target, ".fc-dayGridYear-view");
assert.containsN(target, ".fc-month-container", 12);
assert.equal(target.querySelector(".breadcrumb-item").textContent, "undefined (2016)");
// Tap on a date
await tap(target, ".fc-day-top[data-date='2016-02-05']");
await nextTick(); // switch renderer
await nextTick(); // await breadcrumb update
// Should display day view
assert.containsNone(target, ".fc-dayGridYear-view");
assert.containsOnce(target, ".fc-timeGridDay-view");
assert.equal(
target.querySelector(".breadcrumb-item").textContent,
"undefined (February 5, 2016)"
);
// Change scale to month
await changeScale(target, "month");
assert.containsNone(target, ".fc-timeGridDay-view");
assert.containsOnce(target, ".fc-dayGridMonth-view");
assert.equal(
target.querySelector(".breadcrumb-item").textContent,
"undefined (February 2016)"
);
// Tap on a date
await tap(target, ".fc-day-top[data-date='2016-02-10']");
await nextTick(); // await breadcrumb update
assert.containsNone(target, ".fc-dayGridMonth-view");
assert.containsOnce(target, ".fc-timeGridDay-view");
assert.equal(
target.querySelector(".breadcrumb-item").textContent,
"undefined (February 10, 2016)"
);
});
});
@@ -0,0 +1,169 @@
/** @odoo-module **/
import { CalendarArchParser } from "@web/views/calendar/calendar_arch_parser";
import { FAKE_FIELDS } from "./helpers";
function parseArch(arch, options = {}) {
const parser = new CalendarArchParser();
return parser.parse(arch, { fake: "fields" in options ? options.fields : FAKE_FIELDS }, "fake");
}
function check(assert, paramName, paramValue, expectedName, expectedValue) {
const arch = `<calendar date_start="start_date" ${paramName}="${paramValue}" />`;
const data = parseArch(arch);
assert.strictEqual(data[expectedName], expectedValue);
}
QUnit.module("CalendarView - ArchParser");
QUnit.test("throw if date_start is not set", (assert) => {
assert.throws(() => {
parseArch(`<calendar />`);
});
});
QUnit.test("defaults", (assert) => {
assert.deepEqual(parseArch(`<calendar date_start="start_date" />`), {
canCreate: true,
canDelete: true,
eventLimit: 5,
fieldMapping: {
date_start: "start_date",
},
fieldNames: ["start_date"],
filtersInfo: {},
formViewId: false,
hasEditDialog: false,
hasQuickCreate: true,
isDateHidden: false,
isTimeHidden: false,
popoverFields: {},
scale: "week",
scales: ["day", "week", "month", "year"],
showUnusualDays: false,
});
});
QUnit.test("canCreate", (assert) => {
check(assert, "create", "", "canCreate", true);
check(assert, "create", "true", "canCreate", true);
check(assert, "create", "True", "canCreate", true);
check(assert, "create", "1", "canCreate", true);
check(assert, "create", "false", "canCreate", false);
check(assert, "create", "False", "canCreate", false);
check(assert, "create", "0", "canCreate", false);
});
QUnit.test("canDelete", (assert) => {
check(assert, "delete", "", "canDelete", true);
check(assert, "delete", "true", "canDelete", true);
check(assert, "delete", "True", "canDelete", true);
check(assert, "delete", "1", "canDelete", true);
check(assert, "delete", "false", "canDelete", false);
check(assert, "delete", "False", "canDelete", false);
check(assert, "delete", "0", "canDelete", false);
});
QUnit.test("eventLimit", (assert) => {
check(assert, "event_limit", "2", "eventLimit", 2);
check(assert, "event_limit", "5", "eventLimit", 5);
assert.throws(() => {
parseArch(`<calendar date_start="start_date" event_limit="five" />`);
});
assert.throws(() => {
parseArch(`<calendar date_start="start_date" event_limit="" />`);
});
});
QUnit.test("hasEditDialog", (assert) => {
check(assert, "event_open_popup", "", "hasEditDialog", false);
check(assert, "event_open_popup", "true", "hasEditDialog", true);
check(assert, "event_open_popup", "True", "hasEditDialog", true);
check(assert, "event_open_popup", "1", "hasEditDialog", true);
check(assert, "event_open_popup", "false", "hasEditDialog", false);
check(assert, "event_open_popup", "False", "hasEditDialog", false);
check(assert, "event_open_popup", "0", "hasEditDialog", false);
});
QUnit.test("hasQuickCreate", (assert) => {
check(assert, "quick_add", "", "hasQuickCreate", true);
check(assert, "quick_add", "true", "hasQuickCreate", true);
check(assert, "quick_add", "True", "hasQuickCreate", true);
check(assert, "quick_add", "1", "hasQuickCreate", true);
check(assert, "quick_add", "false", "hasQuickCreate", false);
check(assert, "quick_add", "False", "hasQuickCreate", false);
check(assert, "quick_add", "0", "hasQuickCreate", false);
});
QUnit.test("isDateHidden", (assert) => {
check(assert, "hide_date", "", "isDateHidden", false);
check(assert, "hide_date", "true", "isDateHidden", true);
check(assert, "hide_date", "True", "isDateHidden", true);
check(assert, "hide_date", "1", "isDateHidden", true);
check(assert, "hide_date", "false", "isDateHidden", false);
check(assert, "hide_date", "False", "isDateHidden", false);
check(assert, "hide_date", "0", "isDateHidden", false);
});
QUnit.test("isTimeHidden", (assert) => {
check(assert, "hide_time", "", "isTimeHidden", false);
check(assert, "hide_time", "true", "isTimeHidden", true);
check(assert, "hide_time", "True", "isTimeHidden", true);
check(assert, "hide_time", "1", "isTimeHidden", true);
check(assert, "hide_time", "false", "isTimeHidden", false);
check(assert, "hide_time", "False", "isTimeHidden", false);
check(assert, "hide_time", "0", "isTimeHidden", false);
});
QUnit.test("scale", (assert) => {
check(assert, "mode", "day", "scale", "day");
check(assert, "mode", "week", "scale", "week");
check(assert, "mode", "month", "scale", "month");
check(assert, "mode", "year", "scale", "year");
assert.throws(() => {
parseArch(`<calendar date_start="start_date" mode="other" />`);
});
assert.throws(() => {
parseArch(`<calendar date_start="start_date" mode="" />`);
});
});
QUnit.test("scales", (assert) => {
function check(scales, expectedScales) {
const arch = `<calendar date_start="start_date" scales="${scales}" />`;
const data = parseArch(arch);
assert.deepEqual(data.scales, expectedScales);
}
check("", []);
check("day", ["day"]);
check("day,week", ["day", "week"]);
check("day,week,month", ["day", "week", "month"]);
check("day,week,month,year", ["day", "week", "month", "year"]);
check("week", ["week"]);
check("week,month", ["week", "month"]);
check("week,month,year", ["week", "month", "year"]);
check("month", ["month"]);
check("month,year", ["month", "year"]);
check("year", ["year"]);
check("year,day,month,week", ["year", "day", "month", "week"]);
assert.throws(() => {
parseArch(`<calendar date_start="start_date" scales="month" mode="day" />`);
});
});
QUnit.test("showUnusualDays", (assert) => {
check(assert, "show_unusual_days", "", "showUnusualDays", false);
check(assert, "show_unusual_days", "true", "showUnusualDays", true);
check(assert, "show_unusual_days", "True", "showUnusualDays", true);
check(assert, "show_unusual_days", "1", "showUnusualDays", true);
check(assert, "show_unusual_days", "false", "showUnusualDays", false);
check(assert, "show_unusual_days", "False", "showUnusualDays", false);
check(assert, "show_unusual_days", "0", "showUnusualDays", false);
});
@@ -0,0 +1,222 @@
/** @odoo-module **/
import { CalendarCommonPopover } from "@web/views/calendar/calendar_common/calendar_common_popover";
import { click, getFixture } from "../../helpers/utils";
import { makeEnv, makeFakeDate, makeFakeModel, mountComponent } from "./helpers";
let target;
function makeFakeRecord(data = {}) {
return {
id: 5,
title: "Meeting",
isAllDay: false,
start: makeFakeDate(),
end: makeFakeDate().plus({ hours: 3, minutes: 15 }),
colorIndex: 0,
isTimeHidden: false,
rawRecord: {
name: "Meeting",
},
...data,
};
}
async function start(params = {}) {
const { services, props, model: modelParams } = params;
const env = await makeEnv(services);
const model = makeFakeModel(modelParams);
return await mountComponent(CalendarCommonPopover, env, {
model,
record: makeFakeRecord(),
createRecord() {},
deleteRecord() {},
editRecord() {},
close() {},
...props,
});
}
/** @todo Add tests for fields **/
QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
});
QUnit.test("mount a CalendarCommonPopover", async (assert) => {
await start({});
assert.containsOnce(target, ".popover-header");
assert.strictEqual(target.querySelector(".popover-header").textContent, "Meeting");
assert.containsN(target, ".list-group", 2);
assert.containsOnce(target, ".list-group.o_cw_popover_fields_secondary");
assert.containsOnce(target, ".card-footer .o_cw_popover_edit");
assert.containsOnce(target, ".card-footer .o_cw_popover_delete");
});
QUnit.test("date duration: is all day and is same day", async (assert) => {
await start({
props: {
record: makeFakeRecord({ isAllDay: true, isTimeHidden: true }),
},
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "Friday, July 16, 2021 (All day)");
});
QUnit.test("date duration: is all day and two days duration", async (assert) => {
await start({
props: {
record: makeFakeRecord({
end: makeFakeDate().plus({ days: 1 }),
isAllDay: true,
isTimeHidden: true,
}),
},
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "July 16-17, 2021 (2 days)");
});
QUnit.test("time duration: 1 hour diff", async (assert) => {
await start({
props: {
record: makeFakeRecord({ end: makeFakeDate().plus({ hours: 1 }) }),
},
model: { isDateHidden: true },
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "08:00 - 09:00 (1 hour)");
});
QUnit.test("time duration: 2 hours diff", async (assert) => {
await start({
props: {
record: makeFakeRecord({ end: makeFakeDate().plus({ hours: 2 }) }),
},
model: { isDateHidden: true },
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "08:00 - 10:00 (2 hours)");
});
QUnit.test("time duration: 1 minute diff", async (assert) => {
await start({
props: {
record: makeFakeRecord({ end: makeFakeDate().plus({ minutes: 1 }) }),
},
model: { isDateHidden: true },
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "08:00 - 08:01 (1 minute)");
});
QUnit.test("time duration: 2 minutes diff", async (assert) => {
await start({
props: {
record: makeFakeRecord({ end: makeFakeDate().plus({ minutes: 2 }) }),
},
model: { isDateHidden: true },
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "08:00 - 08:02 (2 minutes)");
});
QUnit.test("time duration: 3 hours and 15 minutes diff", async (assert) => {
await start({
model: { isDateHidden: true },
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "08:00 - 11:15 (3 hours, 15 minutes)");
});
QUnit.test("isDateHidden is true", async (assert) => {
await start({
model: { isDateHidden: true },
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "08:00 - 11:15 (3 hours, 15 minutes)");
});
QUnit.test("isDateHidden is false", async (assert) => {
await start({
model: { isDateHidden: false },
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(
dateTimeLabels,
"Friday, July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)"
);
});
QUnit.test("isTimeHidden is true", async (assert) => {
await start({
props: {
record: makeFakeRecord({ isTimeHidden: true }),
},
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "Friday, July 16, 2021");
});
QUnit.test("isTimeHidden is false", async (assert) => {
await start({
props: {
record: makeFakeRecord({ isTimeHidden: false }),
},
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(
dateTimeLabels,
"Friday, July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)"
);
});
QUnit.test("canDelete is true", async (assert) => {
await start({
model: { canDelete: true },
});
assert.containsOnce(target, ".o_cw_popover_delete");
});
QUnit.test("canDelete is false", async (assert) => {
await start({
model: { canDelete: false },
});
assert.containsNone(target, ".o_cw_popover_delete");
});
QUnit.test("click on delete button", async (assert) => {
assert.expect(2);
await start({
model: { canDelete: true },
props: {
deleteRecord: () => assert.step("delete"),
},
});
await click(target, ".o_cw_popover_delete");
assert.verifySteps(["delete"]);
});
QUnit.test("click on edit button", async (assert) => {
assert.expect(2);
await start({
props: {
editRecord: () => assert.step("edit"),
},
});
await click(target, ".o_cw_popover_edit");
assert.verifySteps(["edit"]);
});
});
@@ -0,0 +1,162 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { CalendarCommonRenderer } from "@web/views/calendar/calendar_common/calendar_common_renderer";
import { getFixture, patchWithCleanup } from "../../helpers/utils";
import {
makeEnv,
makeFakeModel,
mountComponent,
clickAllDaySlot,
selectTimeRange,
clickEvent,
makeFakeDate,
} from "./helpers";
let target;
function makeFakePopoverService(add) {
return { start: () => ({ add }) };
}
async function start(params = {}) {
const { services, props, model: modelParams } = params;
const env = await makeEnv(services);
const model = makeFakeModel(modelParams);
return await mountComponent(CalendarCommonRenderer, env, {
model,
createRecord() {},
deleteRecord() {},
editRecord() {},
displayName: "Plop",
...props,
});
}
QUnit.module("CalendarView - CommonRenderer", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
});
QUnit.test("mount a CalendarCommonRenderer", async (assert) => {
await start({});
assert.containsOnce(target, ".o_calendar_widget.fc");
});
QUnit.test("Day: mount a CalendarCommonRenderer", async (assert) => {
await start({ model: { scale: "day" } });
assert.containsOnce(target, ".o_calendar_widget.fc .fc-timeGridDay-view");
});
QUnit.test("Week: mount a CalendarCommonRenderer", async (assert) => {
await start({ model: { scale: "week" } });
assert.containsOnce(target, ".o_calendar_widget.fc .fc-timeGridWeek-view");
});
QUnit.test("Month: mount a CalendarCommonRenderer", async (assert) => {
await start({ model: { scale: "month" } });
assert.containsOnce(target, ".o_calendar_widget.fc .fc-dayGridMonth-view");
});
QUnit.test("Day: check week number", async (assert) => {
await start({ model: { scale: "day" } });
assert.containsOnce(target, ".fc-week-number");
assert.strictEqual(target.querySelector(".fc-week-number").textContent, "Week 28");
});
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");
});
QUnit.test("Day: click all day slot", async (assert) => {
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout() {},
});
await start({
model: { scale: "day" },
props: {
createRecord(record) {
const date = makeFakeDate().startOf("day");
assert.ok(record.isAllDay);
assert.strictEqual(record.start.valueOf(), date.valueOf());
assert.step("create");
},
},
});
await clickAllDaySlot(target, "2021-07-16");
assert.verifySteps(["create"]);
});
QUnit.test("Day: select range", async (assert) => {
await start({
model: { scale: "day" },
props: {
createRecord(record) {
assert.notOk(record.isAllDay);
assert.strictEqual(
record.start.valueOf(),
luxon.DateTime.local(2021, 7, 16, 8, 0).valueOf()
);
assert.strictEqual(
record.end.valueOf(),
luxon.DateTime.local(2021, 7, 16, 10, 0).valueOf()
);
assert.step("create");
},
},
});
await selectTimeRange(target, "2021-07-16 08:00:00", "2021-07-16 10:00:00");
assert.verifySteps(["create"]);
});
QUnit.test("Day: check event", async (assert) => {
await start({ model: { scale: "day" } });
assert.containsOnce(target, ".o_event");
assert.hasAttrValue(target.querySelector(".o_event"), "data-event-id", "1");
});
QUnit.test("Day: click on event", async (assert) => {
assert.expect(1);
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout() {},
});
await start({
model: { scale: "day" },
services: {
popover: makeFakePopoverService((target, _, props) => {
assert.strictEqual(props.record.id, 1);
return () => {};
}),
},
});
await clickEvent(target, 1);
});
QUnit.test("Week: check week number", async (assert) => {
await start({ model: { scale: "week" } });
assert.containsOnce(target, ".fc-week-number");
assert.strictEqual(target.querySelector(".fc-week-number").textContent, "Week 28");
});
QUnit.test("Week: check dates", async (assert) => {
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 els = target.querySelectorAll(".fc-day-header");
for (let i = 0; i < els.length; i++) {
assert.strictEqual(els[i].textContent, dates[i]);
}
});
});
@@ -0,0 +1,117 @@
/** @odoo-module **/
import { CalendarDatePicker } from "@web/views/calendar/date_picker/calendar_date_picker";
import { click, getFixture, patchDate } from "../../helpers/utils";
import { makeEnv, makeFakeModel, mountComponent } from "./helpers";
let target;
async function start(params = {}) {
const { services, props, model: modelParams } = params;
const env = await makeEnv(services);
const model = makeFakeModel(modelParams);
return await mountComponent(CalendarDatePicker, env, {
model,
...props,
});
}
QUnit.module("CalendarView - DatePicker", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
patchDate(2021, 7, 14, 8, 0, 0);
});
QUnit.test("Mount a CalendarDatePicker", async (assert) => {
await start({ model: { scale: "day" } });
assert.containsOnce(target, ".o_calendar_mini.hasDatepicker");
assert.strictEqual(target.querySelector(".o_selected_range").textContent, "16");
assert.containsOnce(target, `[data-month="6"][data-year="2021"] .o_selected_range`);
assert.strictEqual(target.querySelector(".ui-datepicker-month").textContent, "Jul");
assert.strictEqual(target.querySelector(".ui-datepicker-year").textContent, "2021");
assert.strictEqual(target.querySelector("thead").textContent, "SMTWTFS");
});
QUnit.test("Scale: init with day", async (assert) => {
await start({ model: { scale: "day" } });
assert.containsOnce(target, ".o_selected_range");
assert.containsOnce(target, "a.o_selected_range");
assert.strictEqual(target.querySelector(".o_selected_range").textContent, "16");
});
QUnit.test("Scale: init with week", async (assert) => {
await start({ model: { scale: "week" } });
assert.containsOnce(target, ".o_selected_range");
assert.containsOnce(target, "tr.o_selected_range");
assert.hasClass(target.querySelector("tr.o_selected_range"), "o_color");
assert.strictEqual(target.querySelector(".o_selected_range").textContent, "11121314151617");
});
QUnit.test("Scale: init with month", async (assert) => {
await start({ model: { scale: "month" } });
assert.containsN(target, "td.o_selected_range", 35);
});
QUnit.test("Scale: init with year", async (assert) => {
await start({ model: { scale: "year" } });
assert.containsN(target, "td.o_selected_range", 35);
});
QUnit.test("First day: 0 = Sunday", async (assert) => {
await start({ model: { scale: "day", firstDayOfWeek: 0 } });
assert.strictEqual(target.querySelector("thead").textContent, "SMTWTFS");
});
QUnit.test("First day: 1 = Monday", async (assert) => {
await start({ model: { scale: "day", firstDayOfWeek: 1 } });
assert.strictEqual(target.querySelector("thead").textContent, "MTWTFSS");
});
QUnit.test("Click on active day should change scale : day -> month", async (assert) => {
assert.expect(2);
await start({
model: {
scale: "day",
load(params) {
assert.strictEqual(params.scale, "month");
assert.ok(params.date.equals(luxon.DateTime.local(2021, 7, 16)));
},
},
});
await click(target, ".ui-state-active");
});
QUnit.test("Click on active day should change scale : month -> week", async (assert) => {
assert.expect(2);
await start({
model: {
scale: "month",
load(params) {
assert.strictEqual(params.scale, "week");
assert.ok(params.date.equals(luxon.DateTime.local(2021, 7, 16)));
},
},
});
await click(target, ".ui-state-active");
});
QUnit.test("Click on active day should change scale : week -> day", async (assert) => {
assert.expect(2);
await start({
model: {
scale: "week",
load(params) {
assert.strictEqual(params.scale, "day");
assert.ok(params.date.equals(luxon.DateTime.local(2021, 7, 16)));
},
},
});
await click(target, ".ui-state-active");
});
});
@@ -0,0 +1,213 @@
/** @odoo-module **/
import { CalendarFilterPanel } from "@web/views/calendar/filter_panel/calendar_filter_panel";
import { click, getFixture, triggerEvent } from "../../helpers/utils";
import { makeEnv, makeFakeModel, mountComponent } from "./helpers";
let target;
async function start(params = {}) {
const { services, props, model: modelParams } = params;
const env = await makeEnv(services);
const model = makeFakeModel(modelParams);
return await mountComponent(CalendarFilterPanel, env, {
model,
...props,
});
}
QUnit.module("CalendarView - FilterPanel", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
});
QUnit.test("render filter panel", async (assert) => {
await start({});
assert.containsN(target, ".o_calendar_filter", 2);
const sections = target.querySelectorAll(".o_calendar_filter");
let header = sections[0].querySelector(".o_cw_filter_label");
assert.strictEqual(header.textContent, "Attendees");
assert.containsN(sections[0], ".o_calendar_filter_item", 4);
header = sections[1].querySelector(".o_cw_filter_label");
assert.strictEqual(header.textContent, "Users");
assert.containsN(sections[1], ".o_calendar_filter_item", 2);
});
QUnit.test("filters are correctly sorted", async (assert) => {
await start({});
assert.containsN(target, ".o_calendar_filter", 2);
const sections = target.querySelectorAll(".o_calendar_filter");
let header = sections[0].querySelector(".o_cw_filter_label");
assert.strictEqual(header.textContent, "Attendees");
assert.containsN(sections[0], ".o_calendar_filter_item", 4);
assert.strictEqual(
sections[0].textContent.trim(),
"AttendeesMitchell AdminMarc DemoBrandon FreemanEverybody's calendar"
);
header = sections[1].querySelector(".o_cw_filter_label");
assert.strictEqual(header.textContent, "Users");
assert.containsN(sections[1], ".o_calendar_filter_item", 2);
assert.strictEqual(sections[1].textContent.trim(), "UsersMarc DemoBrandon Freeman");
});
QUnit.test("section can collapse", async (assert) => {
await start({});
const section = target.querySelectorAll(".o_calendar_filter")[0];
assert.containsOnce(section, ".o_cw_filter_collapse_icon");
assert.containsN(section, ".o_calendar_filter_item", 4);
await click(section, ".o_cw_filter_label");
assert.containsNone(section, ".o_calendar_filter_item");
await click(section, ".o_cw_filter_label");
assert.containsN(section, ".o_calendar_filter_item", 4);
});
QUnit.test("section cannot collapse", async (assert) => {
await start({});
const section = target.querySelectorAll(".o_calendar_filter")[1];
assert.containsNone(section, ".o_cw_filter_label > i");
assert.doesNotHaveClass(section, "o_calendar_filter-collapsed");
assert.containsN(section, ".o_calendar_filter_item", 2);
await click(section, ".o_cw_filter_label");
assert.doesNotHaveClass(section, "o_calendar_filter-collapsed");
assert.containsN(section, ".o_calendar_filter_item", 2);
});
QUnit.test("filters can have avatar", async (assert) => {
await start({});
const section = target.querySelectorAll(".o_calendar_filter")[0];
const filters = section.querySelectorAll(".o_calendar_filter_item");
assert.containsN(section, ".o_cw_filter_avatar", 4);
assert.containsN(section, "img.o_cw_filter_avatar", 3);
assert.containsOnce(section, "i.o_cw_filter_avatar");
assert.hasAttrValue(
filters[0].querySelector(".o_cw_filter_avatar"),
"data-src",
"/web/image/res.partner/3/avatar_128"
);
assert.hasAttrValue(
filters[1].querySelector(".o_cw_filter_avatar"),
"data-src",
"/web/image/res.partner/6/avatar_128"
);
assert.hasAttrValue(
filters[2].querySelector(".o_cw_filter_avatar"),
"data-src",
"/web/image/res.partner/4/avatar_128"
);
});
QUnit.test("filters cannot have avatar", async (assert) => {
await start({});
const section = target.querySelectorAll(".o_calendar_filter")[1];
assert.containsN(section, ".o_calendar_filter_item", 2);
assert.containsNone(section, ".o_cw_filter_avatar");
});
QUnit.test("filter can have remove button", async (assert) => {
await start({});
const section = target.querySelectorAll(".o_calendar_filter")[0];
const filters = section.querySelectorAll(".o_calendar_filter_item");
assert.containsN(section, ".o_calendar_filter_item", 4);
assert.containsN(section, ".o_calendar_filter_item .o_remove", 2);
assert.containsNone(filters[0], ".o_remove");
assert.containsOnce(filters[1], ".o_remove");
assert.containsOnce(filters[2], ".o_remove");
assert.containsNone(filters[3], ".o_remove");
});
QUnit.test("click on remove button", async (assert) => {
assert.expect(3);
await start({
model: {
unlinkFilter(fieldName, recordId) {
assert.step(`${fieldName} ${recordId}`);
},
},
});
const section = target.querySelectorAll(".o_calendar_filter")[0];
const filters = section.querySelectorAll(".o_calendar_filter_item");
await click(filters[1], ".o_calendar_filter_item .o_remove");
await click(filters[2], ".o_calendar_filter_item .o_remove");
assert.verifySteps(["partner_ids 2", "partner_ids 1"]);
});
QUnit.test("click on filter", async (assert) => {
assert.expect(6);
await start({
model: {
updateFilters(fieldName, args) {
assert.step(`${fieldName} ${Object.keys(args)[0]} ${Object.values(args)[0]}`);
},
},
});
const section = target.querySelectorAll(".o_calendar_filter")[0];
const filters = section.querySelectorAll(".o_calendar_filter_item");
await click(filters[0], "input");
await click(filters[1], "input");
await click(filters[2], "input");
await click(filters[3], "input");
await click(filters[3], "input");
assert.verifySteps([
"partner_ids 3 false",
"partner_ids 6 true",
"partner_ids 4 false",
"partner_ids all true",
"partner_ids all false",
]);
});
QUnit.test("hover filter opens tooltip", async (assert) => {
await start({
services: {
popover: {
start: () => ({
add: (target, _, props) => {
assert.step(props.filter.label);
assert.step("" + props.filter.hasAvatar);
assert.step("" + props.filter.value);
return () => {
assert.step("popOver Closed");
};
},
}),
},
},
});
const section = target.querySelectorAll(".o_calendar_filter")[0];
const filters = section.querySelectorAll(".o_calendar_filter_item");
await triggerEvent(filters[0], null, "mouseenter");
assert.verifySteps(["Mitchell Admin", "true", "3"]);
await triggerEvent(filters[0], null, "mouseleave");
assert.verifySteps(["popOver Closed"]);
await triggerEvent(filters[3], null, "mouseenter");
assert.verifySteps([]);
await triggerEvent(filters[3], null, "mouseleave");
assert.verifySteps([]);
});
});
@@ -0,0 +1,119 @@
/** @odoo-module **/
import { CalendarQuickCreate } from "@web/views/calendar/quick_create/calendar_quick_create";
import { click, getFixture } from "../../helpers/utils";
import { makeEnv, makeFakeModel, mountComponent } from "./helpers";
let target;
async function start(params = {}) {
const { services, props, model: modelParams } = params;
const env = await makeEnv(services);
env.dialogData = {
isActive: true,
close() {},
};
const model = makeFakeModel(modelParams);
return await mountComponent(CalendarQuickCreate, env, {
model,
record: {},
close() {},
editRecord() {},
...props,
});
}
QUnit.module("CalendarView - QuickCreate", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
});
QUnit.test("mount a CalendarQuickCreate", async (assert) => {
await start({});
assert.containsOnce(target, ".o-calendar-quick-create");
assert.containsOnce(target, ".o_dialog .modal-sm");
assert.strictEqual(target.querySelector(".modal-title").textContent, "New Event");
assert.strictEqual(target.querySelector(`input[name="title"]`), document.activeElement);
assert.containsOnce(target, ".o-calendar-quick-create--create-btn");
assert.containsOnce(target, ".o-calendar-quick-create--edit-btn");
assert.containsOnce(target, ".o-calendar-quick-create--cancel-btn");
});
QUnit.test("click on create button", async (assert) => {
assert.expect(2);
await start({
props: {
close: () => assert.step("close"),
},
model: {
createRecord: () => assert.step("create"),
},
});
await click(target, ".o-calendar-quick-create--create-btn");
assert.verifySteps([]);
assert.hasClass(target.querySelector("input[name=title]"), "o_field_invalid");
});
QUnit.test("click on create button (with name)", async (assert) => {
assert.expect(4);
await start({
props: {
close: () => assert.step("close"),
},
model: {
createRecord(record) {
assert.step("create");
assert.strictEqual(record.title, "TEST");
},
},
});
const input = target.querySelector(".o-calendar-quick-create--input");
input.value = "TEST";
await click(target, ".o-calendar-quick-create--create-btn");
assert.verifySteps(["create", "close"]);
});
QUnit.test("click on edit button", async (assert) => {
assert.expect(3);
await start({
props: {
close: () => assert.step("close"),
editRecord: () => assert.step("edit"),
},
});
await click(target, ".o-calendar-quick-create--edit-btn");
assert.verifySteps(["edit", "close"]);
});
QUnit.test("click on edit button (with name)", async (assert) => {
assert.expect(4);
await start({
props: {
close: () => assert.step("close"),
editRecord(record) {
assert.step("edit");
assert.strictEqual(record.title, "TEST");
},
},
});
const input = target.querySelector(".o-calendar-quick-create--input");
input.value = "TEST";
await click(target, ".o-calendar-quick-create--edit-btn");
assert.verifySteps(["edit", "close"]);
});
QUnit.test("click on cancel button", async (assert) => {
assert.expect(2);
await start({
props: {
close: () => assert.step("close"),
},
});
await click(target, ".o-calendar-quick-create--cancel-btn");
assert.verifySteps(["close"]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
/** @odoo-module **/
import { CalendarYearPopover } from "@web/views/calendar/calendar_year/calendar_year_popover";
import { click, getFixture } from "../../helpers/utils";
import { mountComponent, makeEnv, makeFakeModel, makeFakeRecords, makeFakeDate } from "./helpers";
let target, fakePopoverRecords;
async function start(params = {}) {
const { services, props, model: modelParams } = params;
const env = await makeEnv(services);
const model = makeFakeModel(modelParams);
return await mountComponent(CalendarYearPopover, env, {
model,
date: makeFakeDate(),
records: fakePopoverRecords,
createRecord() {},
deleteRecord() {},
editRecord() {},
close() {},
...props,
});
}
QUnit.module("CalendarView - YearPopover", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
fakePopoverRecords = [
{
id: 1,
start: makeFakeDate(),
end: makeFakeDate(),
isAllDay: true,
title: "R1",
},
{
id: 2,
start: makeFakeDate().set({ hours: 14 }),
end: makeFakeDate().set({ hours: 16 }),
isAllDay: false,
title: "R2",
},
{
id: 3,
start: makeFakeDate().minus({ days: 1 }),
end: makeFakeDate().plus({ days: 1 }),
isAllDay: true,
title: "R3",
},
{
id: 4,
start: makeFakeDate().minus({ days: 3 }),
end: makeFakeDate().plus({ days: 1 }),
isAllDay: true,
title: "R4",
},
{
id: 5,
start: makeFakeDate().minus({ days: 1 }),
end: makeFakeDate().plus({ days: 3 }),
isAllDay: true,
title: "R5",
},
];
});
QUnit.test("canCreate is true", async (assert) => {
await start({ model: { canCreate: true } });
assert.containsOnce(target, ".o_cw_popover_create");
});
QUnit.test("canCreate is false", async (assert) => {
await start({ model: { canCreate: false } });
assert.containsNone(target, ".o_cw_popover_create");
});
QUnit.test("click on create button", async (assert) => {
assert.expect(3);
await start({
props: {
createRecord: () => assert.step("create"),
},
model: { canCreate: true },
});
assert.containsOnce(target, ".o_cw_popover_create");
await click(target, ".o_cw_popover_create");
assert.verifySteps(["create"]);
});
QUnit.test("group records", async (assert) => {
await start({});
assert.containsN(target, ".o_cw_body > div", 4);
assert.containsN(target, ".o_cw_body > a", 5);
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(
target.querySelector(".o_cw_body").textContent.trim(),
"July 16, 2021R114:00 R2July 13-17, 2021R4July 15-17, 2021R3July 15-19, 2021R5"
);
});
QUnit.test("click on record", async (assert) => {
assert.expect(3);
await start({
props: {
records: [makeFakeRecords()[3]],
editRecord: () => assert.step("edit"),
},
});
assert.containsOnce(target, ".o_cw_body > a");
await click(target, ".o_cw_body > a");
assert.verifySteps(["edit"]);
});
});
@@ -0,0 +1,139 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { CalendarYearRenderer } from "@web/views/calendar/calendar_year/calendar_year_renderer";
import { getFixture, patchWithCleanup } from "../../helpers/utils";
import { clickDate, mountComponent, selectDateRange, makeEnv, makeFakeModel } from "./helpers";
let target;
async function start(params = {}) {
const { services, props, model: modelParams } = params;
const env = await makeEnv(services);
const model = makeFakeModel(modelParams);
return await mountComponent(CalendarYearRenderer, env, {
model,
createRecord() {},
deleteRecord() {},
editRecord() {},
...props,
});
}
QUnit.module("CalendarView - YearRenderer", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
});
QUnit.test("mount a CalendarYearRenderer", async (assert) => {
await start({});
assert.containsN(target, ".fc-month-container", 12);
const monthHeaders = target.querySelectorAll(".fc-header-toolbar .fc-center");
// check "title format"
assert.strictEqual(monthHeaders.length, 12);
const monthTitles = [
"Jan 2021",
"Feb 2021",
"Mar 2021",
"Apr 2021",
"May 2021",
"Jun 2021",
"Jul 2021",
"Aug 2021",
"Sep 2021",
"Oct 2021",
"Nov 2021",
"Dec 2021",
];
for (let i = 0; i < 12; i++) {
assert.strictEqual(monthHeaders[i].textContent, monthTitles[i]);
}
const dayHeaders = target
.querySelector(".fc-month-container")
.querySelectorAll(".fc-day-header");
// check day header format
assert.strictEqual(dayHeaders.length, 7);
const dayTitles = ["S", "M", "T", "W", "T", "F", "S"];
for (let i = 0; i < 7; i++) {
assert.strictEqual(dayHeaders[i].textContent, dayTitles[i]);
}
// check showNonCurrentDates
assert.containsN(target, ".fc-day-number", 365);
});
QUnit.test("display events", async (assert) => {
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
await start({
props: {
createRecord(record) {
assert.step(`${record.start.toISODate()} allDay:${record.isAllDay} no event`);
},
},
services: {
popover: {
start: () => ({
add: (target, _, props) => {
assert.step(`${props.date.toISODate()} ${props.records[0].title}`);
return () => {};
},
}),
},
},
});
await clickDate(target, "2021-07-15");
assert.verifySteps(["2021-07-15 allDay:true no event"]);
await clickDate(target, "2021-07-16");
assert.verifySteps(["2021-07-16 1 day, all day in July"]);
await clickDate(target, "2021-07-17");
assert.verifySteps(["2021-07-17 allDay:true no event"]);
await clickDate(target, "2021-07-18");
assert.verifySteps(["2021-07-18 3 days, all day in July"]);
await clickDate(target, "2021-07-19");
assert.verifySteps(["2021-07-19 3 days, all day in July"]);
await clickDate(target, "2021-07-20");
assert.verifySteps(["2021-07-20 3 days, all day in July"]);
await clickDate(target, "2021-07-21");
assert.verifySteps(["2021-07-21 allDay:true no event"]);
await clickDate(target, "2021-06-28");
assert.verifySteps(["2021-06-28 allDay:true no event"]);
await clickDate(target, "2021-06-29");
assert.verifySteps(["2021-06-29 Over June and July"]);
await clickDate(target, "2021-06-30");
assert.verifySteps(["2021-06-30 Over June and July"]);
await clickDate(target, "2021-07-01");
assert.verifySteps(["2021-07-01 Over June and July"]);
await clickDate(target, "2021-07-02");
assert.verifySteps(["2021-07-02 Over June and July"]);
await clickDate(target, "2021-07-03");
assert.verifySteps(["2021-07-03 Over June and July"]);
await clickDate(target, "2021-07-04");
assert.verifySteps(["2021-07-04 allDay:true no event"]);
});
QUnit.test("select a range of date", async (assert) => {
assert.expect(3);
await start({
props: {
createRecord(record) {
assert.ok(record.isAllDay);
assert.ok(record.start.equals(luxon.DateTime.local(2021, 7, 2, 0, 0, 0, 0)));
assert.ok(record.end.equals(luxon.DateTime.local(2021, 7, 5, 0, 0, 0, 0)));
},
},
});
await selectDateRange(target, "2021-07-02", "2021-07-05");
});
});
@@ -0,0 +1,536 @@
/** @odoo-module **/
import { uiService } from "@web/core/ui/ui_service";
import { registry } from "@web/core/registry";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { click, getFixture, mount, nextTick, triggerEvent } from "../../helpers/utils";
import { setupViewRegistries } from "@web/../tests/views/helpers";
export function makeEnv(services = {}) {
clearRegistryWithCleanup(registry.category("main_components"));
setupViewRegistries();
services = Object.assign(
{
ui: uiService,
},
services
);
for (const [key, service] of Object.entries(services)) {
registry.category("services").add(key, service, { force: true });
}
return makeTestEnv({
config: {
setDisplayName: () => {},
},
});
}
//------------------------------------------------------------------------------
export async function mountComponent(C, env, props) {
const target = getFixture();
return await mount(C, target, { env, props });
}
//------------------------------------------------------------------------------
export function makeFakeDate() {
return luxon.DateTime.local(2021, 7, 16, 8, 0, 0, 0);
}
export function makeFakeRecords() {
return {
1: {
id: 1,
title: "1 day, all day in July",
start: makeFakeDate(),
isAllDay: true,
end: makeFakeDate(),
},
2: {
id: 2,
title: "3 days, all day in July",
start: makeFakeDate().plus({ days: 2 }),
isAllDay: true,
end: makeFakeDate().plus({ days: 4 }),
},
3: {
id: 3,
title: "1 day, all day in June",
start: makeFakeDate().plus({ months: -1 }),
isAllDay: true,
end: makeFakeDate().plus({ months: -1 }),
},
4: {
id: 4,
title: "3 days, all day in June",
start: makeFakeDate().plus({ months: -1, days: 2 }),
isAllDay: true,
end: makeFakeDate().plus({ months: -1, days: 4 }),
},
5: {
id: 5,
title: "Over June and July",
start: makeFakeDate().startOf("month").plus({ days: -2 }),
isAllDay: true,
end: makeFakeDate().startOf("month").plus({ days: 2 }),
},
};
}
export const FAKE_FILTER_SECTIONS = [
{
label: "Attendees",
fieldName: "partner_ids",
avatar: {
model: "res.partner",
field: "avatar_128",
},
hasAvatar: true,
write: {
model: "filter_partner",
field: "partner_id",
},
canCollapse: true,
canAddFilter: true,
filters: [
{
type: "user",
label: "Mitchell Admin",
active: true,
value: 3,
colorIndex: 3,
recordId: null,
canRemove: false,
hasAvatar: true,
},
{
type: "all",
label: "Everybody's calendar",
active: false,
value: "all",
colorIndex: null,
recordId: null,
canRemove: false,
hasAvatar: false,
},
{
type: "record",
label: "Brandon Freeman",
active: true,
value: 4,
colorIndex: 4,
recordId: 1,
canRemove: true,
hasAvatar: true,
},
{
type: "record",
label: "Marc Demo",
active: false,
value: 6,
colorIndex: 6,
recordId: 2,
canRemove: true,
hasAvatar: true,
},
],
},
{
label: "Users",
fieldName: "user_id",
avatar: {
model: null,
field: null,
},
hasAvatar: false,
write: {
model: null,
field: null,
},
canCollapse: false,
canAddFilter: false,
filters: [
{
type: "record",
label: "Brandon Freeman",
active: false,
value: 1,
colorIndex: false,
recordId: null,
canRemove: true,
hasAvatar: true,
},
{
type: "record",
label: "Marc Demo",
active: false,
value: 2,
colorIndex: false,
recordId: null,
canRemove: true,
hasAvatar: true,
},
],
},
];
export const FAKE_FIELDS = {
id: { string: "Id", type: "integer" },
user_id: { string: "User", type: "many2one", relation: "user", default: -1 },
partner_id: {
string: "Partner",
type: "many2one",
relation: "partner",
related: "user_id.partner_id",
default: 1,
},
name: { string: "Name", type: "char" },
start_date: { string: "Start Date", type: "date" },
stop_date: { string: "Stop Date", type: "date" },
start: { string: "Start Datetime", type: "datetime" },
stop: { string: "Stop Datetime", type: "datetime" },
delay: { string: "Delay", type: "float" },
allday: { string: "Is All Day", type: "boolean" },
partner_ids: {
string: "Attendees",
type: "one2many",
relation: "partner",
default: [[6, 0, [1]]],
},
type: { string: "Type", type: "integer" },
event_type_id: { string: "Event Type", type: "many2one", relation: "event_type" },
color: { string: "Color", type: "integer", related: "event_type_id.color" },
};
function makeFakeModelState() {
return {
canCreate: true,
canDelete: true,
canEdit: true,
date: makeFakeDate(),
fieldMapping: {
date_start: "start_date",
date_stop: "stop_date",
date_delay: "delay",
all_day: "allday",
color: "color",
},
fieldNames: ["start_date", "stop_date", "color", "delay", "allday", "user_id"],
fields: FAKE_FIELDS,
filterSections: FAKE_FILTER_SECTIONS,
firstDayOfWeek: 0,
isDateHidden: false,
isTimeHidden: false,
hasAllDaySlot: true,
hasEditDialog: false,
hasQuickCreate: false,
popoverFields: {
name: { rawAttrs: {}, options: {} },
},
rangeEnd: makeFakeDate().endOf("month"),
rangeStart: makeFakeDate().startOf("month"),
records: makeFakeRecords(),
resModel: "event",
scale: "month",
scales: ["day", "week", "month", "year"],
unusualDays: [],
};
}
export function makeFakeModel(state = {}) {
return {
...makeFakeModelState(),
load() {},
createFilter() {},
createRecord() {},
unlinkFilter() {},
unlinkRecord() {},
updateFilter() {},
updateRecord() {},
...state,
};
}
// DOM Utils
//------------------------------------------------------------------------------
async function scrollTo(el, scrollParam) {
el.scrollIntoView(scrollParam);
await new Promise(window.requestAnimationFrame);
}
export function findPickedDate(target) {
return target.querySelector(".ui-datepicker-current-day");
}
export async function pickDate(target, date) {
const [year, month, day] = date.split("-");
const iMonth = parseInt(month, 10) - 1;
const iDay = parseInt(day, 10) - 1;
const el = target.querySelectorAll(
`.ui-datepicker-calendar td[data-year="${year}"][data-month="${iMonth}"]`
)[iDay];
el.scrollIntoView();
await click(el);
}
function findAllDaySlot(target, date) {
return target.querySelector(`.fc-day-grid .fc-day[data-date="${date}"]`);
}
export function findDateCell(target, date) {
return target.querySelector(`.fc-day-top[data-date="${date}"]`);
}
export function findEvent(target, eventId) {
return target.querySelector(`.fc-event[data-event-id="${eventId}"]`);
}
function findDateCol(target, date) {
return target.querySelector(`.fc-day-header[data-date="${date}"]`);
}
export function findTimeRow(target, time) {
return target.querySelector(`.fc-slats [data-time="${time}"] .fc-widget-content`);
}
async function triggerEventForCalendar(el, type, position = {}) {
const rect = el.getBoundingClientRect();
const x = position.x || rect.x + rect.width / 2;
const y = position.y || rect.y + rect.height / 2;
const attrs = {
which: 1,
clientX: x,
clientY: y,
};
await triggerEvent(el, null, type, attrs);
}
export async function clickAllDaySlot(target, date) {
const el = findAllDaySlot(target, date);
await scrollTo(el);
await triggerEventForCalendar(el, "mousedown");
await triggerEventForCalendar(el, "mouseup");
await nextTick();
}
export async function clickDate(target, date) {
const el = findDateCell(target, date);
await scrollTo(el);
await triggerEventForCalendar(el, "mousedown");
await triggerEventForCalendar(el, "mouseup");
await nextTick();
}
export async function clickEvent(target, eventId) {
const el = findEvent(target, eventId);
await scrollTo(el);
await click(el);
await nextTick();
}
export async function selectTimeRange(target, startDateTime, endDateTime) {
const [startDate, startTime] = startDateTime.split(" ");
const [endDate, endTime] = endDateTime.split(" ");
const startCol = findDateCol(target, startDate);
const endCol = findDateCol(target, endDate);
const startRow = findTimeRow(target, startTime);
const endRow = findTimeRow(target, endTime);
await scrollTo(startRow);
const startColRect = startCol.getBoundingClientRect();
const startRowRect = startRow.getBoundingClientRect();
await triggerEventForCalendar(startRow, "mousedown", {
x: startColRect.x + startColRect.width / 2,
y: startRowRect.y + 1,
});
await scrollTo(endRow, false);
const endColRect = endCol.getBoundingClientRect();
const endRowRect = endRow.getBoundingClientRect();
await triggerEventForCalendar(endRow, "mousemove", {
x: endColRect.x + endColRect.width / 2,
y: endRowRect.y - 1,
});
await triggerEventForCalendar(endRow, "mouseup", {
x: endColRect.x + endColRect.width / 2,
y: endRowRect.y - 1,
});
await nextTick();
}
export async function selectDateRange(target, startDate, endDate) {
const start = findDateCell(target, startDate);
const end = findDateCell(target, endDate);
await scrollTo(start);
await triggerEventForCalendar(start, "mousedown");
await scrollTo(end);
await triggerEventForCalendar(end, "mousemove");
await triggerEventForCalendar(end, "mouseup");
await nextTick();
}
export async function selectAllDayRange(target, startDate, endDate) {
const start = findAllDaySlot(target, startDate);
const end = findAllDaySlot(target, endDate);
await scrollTo(start);
await triggerEventForCalendar(start, "mousedown");
await scrollTo(end);
await triggerEventForCalendar(end, "mousemove");
await triggerEventForCalendar(end, "mouseup");
await nextTick();
}
export async function moveEventToDate(target, eventId, date, options = {}) {
const event = findEvent(target, eventId);
const cell = findDateCell(target, date);
await scrollTo(event);
await triggerEventForCalendar(event, "mousedown");
await scrollTo(cell);
await triggerEventForCalendar(cell, "mousemove");
if (!options.disableDrop) {
await triggerEventForCalendar(cell, "mouseup");
}
await nextTick();
}
export async function moveEventToTime(target, eventId, dateTime) {
const event = findEvent(target, eventId);
const [date, time] = dateTime.split(" ");
const col = findDateCol(target, date);
const row = findTimeRow(target, time);
// Find event position
await scrollTo(event);
const eventRect = event.getBoundingClientRect();
const eventPos = {
x: eventRect.x + eventRect.width / 2,
y: eventRect.y,
};
await triggerEventForCalendar(event, "mousedown", eventPos);
// Find target position
await scrollTo(row, false);
const colRect = col.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const toPos = {
x: colRect.x + colRect.width / 2,
y: rowRect.y - 1,
};
await triggerEventForCalendar(row, "mousemove", toPos);
await triggerEventForCalendar(row, "mouseup", toPos);
await nextTick();
}
export async function moveEventToAllDaySlot(target, eventId, date) {
const event = findEvent(target, eventId);
const slot = findAllDaySlot(target, date);
// Find event position
await scrollTo(event);
const eventRect = event.getBoundingClientRect();
const eventPos = {
x: eventRect.x + eventRect.width / 2,
y: eventRect.y,
};
await triggerEventForCalendar(event, "mousedown", eventPos);
// Find target position
await scrollTo(slot);
const slotRect = slot.getBoundingClientRect();
const toPos = {
x: slotRect.x + slotRect.width / 2,
y: slotRect.y - 1,
};
await triggerEventForCalendar(slot, "mousemove", toPos);
await triggerEventForCalendar(slot, "mouseup", toPos);
await nextTick();
}
export async function resizeEventToTime(target, eventId, dateTime) {
const event = findEvent(target, eventId);
const [date, time] = dateTime.split(" ");
const col = findDateCol(target, date);
const row = findTimeRow(target, time);
// Find event position
await scrollTo(event);
await triggerEventForCalendar(event, "mouseenter");
// Find event resizer
const resizer = event.querySelector(".fc-end-resizer");
resizer.style.display = "block";
resizer.style.width = "100%";
resizer.style.height = "1em";
resizer.style.bottom = "0";
const resizerRect = resizer.getBoundingClientRect();
const resizerPos = {
x: resizerRect.x + resizerRect.width / 2,
y: resizerRect.y + resizerRect.height / 2,
};
await triggerEventForCalendar(resizer, "mousedown", resizerPos);
// Find target position
await scrollTo(row, false);
const colRect = col.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const toPos = {
x: colRect.x + colRect.width / 2,
y: rowRect.y - 1,
};
await triggerEventForCalendar(row, "mousemove", toPos);
await triggerEventForCalendar(row, "mouseup", toPos);
await nextTick();
}
export async function changeScale(target, scale) {
await click(target, `.o_calendar_scale_buttons .scale_button_selection`);
await click(target, `.o_calendar_scale_buttons .o_calendar_button_${scale}`);
await nextTick();
}
export async function navigate(target, direction) {
await click(target, `.o_calendar_navigation_buttons .o_calendar_button_${direction}`);
}
export function findFilterPanelSection(target, sectionName) {
return target.querySelector(`.o_calendar_filter[data-name="${sectionName}"]`);
}
export function findFilterPanelFilter(target, sectionName, filterValue) {
return findFilterPanelSection(target, sectionName).querySelector(
`.o_calendar_filter_item[data-value="${filterValue}"]`
);
}
export function findFilterPanelSectionFilter(target, sectionName) {
return findFilterPanelSection(target, sectionName).querySelector(
`.o_calendar_filter_items_checkall`
);
}
export async function toggleFilter(target, sectionName, filterValue) {
const el = findFilterPanelFilter(target, sectionName, filterValue).querySelector(`input`);
await scrollTo(el);
await click(el);
}
export async function toggleSectionFilter(target, sectionName) {
const el = findFilterPanelSectionFilter(target, sectionName).querySelector(`input`);
await scrollTo(el);
await click(el);
}
+1
View File
@@ -41,6 +41,7 @@
"luxon": "readonly",
"moment": "readonly",
"py": "readonly",
"FullCalendar": "readonly",
"ClipboardJS": "readonly",
"globalThis": "readonly"
}
+1
View File
@@ -34,6 +34,7 @@
"luxon": "readonly",
"moment": "readonly",
"py": "readonly",
"FullCalendar": "readonly",
"ClipboardJS": "readonly",
"Modal": "readonly",
"Dropdown": "readonly",