[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:
co-authored by
Jorge Pinna Puissant
parent
3f15989e15
commit
ed9a9d024a
@@ -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"),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+28
@@ -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";
|
||||
+25
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
"luxon": "readonly",
|
||||
"moment": "readonly",
|
||||
"py": "readonly",
|
||||
"FullCalendar": "readonly",
|
||||
"ClipboardJS": "readonly",
|
||||
"globalThis": "readonly"
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"luxon": "readonly",
|
||||
"moment": "readonly",
|
||||
"py": "readonly",
|
||||
"FullCalendar": "readonly",
|
||||
"ClipboardJS": "readonly",
|
||||
"Modal": "readonly",
|
||||
"Dropdown": "readonly",
|
||||
|
||||
Reference in New Issue
Block a user