Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,196 @@
/** @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")) {
// Don't use archParseBoolean from `utils.js` because it does not interpret integers
hasQuickCreate = !/^(false|0)$/i.test(node.getAttribute("quick_add"));
}
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,
};
}
}

View file

@ -0,0 +1,107 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { is24HourFormat } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { Field } from "@web/views/fields/field";
import { Record } from "@web/views/record";
import { getFormattedDateSpan } from '@web/views/calendar/utils';
import { Component } from "@odoo/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;
}
getFormattedValue(fieldName, record) {
const fieldInfo = this.props.model.popoverFields[fieldName];
const field = this.props.model.fields[fieldName];
let format;
const formattersRegistry = registry.category("formatters");
if (fieldInfo.widget && formattersRegistry.contains(fieldInfo.widget)) {
format = formattersRegistry.get(fieldInfo.widget);
} else {
format = formattersRegistry.get(field.type);
}
return format(record.data[fieldName]);
}
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 = getFormattedDateSpan(start, end);
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")}'`);
}
}
}
}
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.subTemplates = {
popover: "web.CalendarCommonPopover.popover",
body: "web.CalendarCommonPopover.body",
footer: "web.CalendarCommonPopover.footer",
};
CalendarCommonPopover.props = {
close: Function,
record: Object,
model: Object,
createRecord: Function,
deleteRecord: Function,
editRecord: Function,
};

View file

@ -0,0 +1,75 @@
// 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_field .o_field_widget {
@include o-text-overflow(block);
}
.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;
}
}
}
}

View file

@ -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="{{ constructor.subTemplates.body }}" />
<t t-set-slot="footer">
<t t-call="{{ constructor.subTemplates.footer }}" />
</t>
</Dialog>
</t>
<t t-else="">
<t t-call="{{ constructor.subTemplates.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 text-truncate">
<span class="popover-header border-0" t-esc="props.record.title" t-att-data-tooltip="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="{{ constructor.subTemplates.body }}" />
<div class="card-footer border-top" t-att-class="{ 'o_footer_shrink': !isEventEditable and !isEventDeletable }">
<t t-call="{{ constructor.subTemplates.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]" />
<t t-set="fieldType" t-value="props.model.fields[fieldName].type" />
<li class="list-group-item d-flex text-nowrap align-items-center" t-att-class="fieldInfo.rawAttrs.class" t-att-data-tooltip="fieldType === 'html' ? '' : getFormattedValue(fieldName, slot.record)">
<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="o_cw_popover_field overflow-hidden">
<Field name="fieldName" record="slot.record" fieldInfo="fieldInfo" type="fieldInfo.widget" />
</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>

View file

@ -0,0 +1,291 @@
/** @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";
import { Component, useEffect } from "@odoo/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,
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,
timeGridEventMinHeight : 15,
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) {
const color = getColor(record.colorIndex);
this.popover.open(
target,
this.getPopoverProps(record),
`o_cw_popover o_calendar_color_${typeof(color) === "number" ? color : 0}`
);
}
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) {
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_0");
}
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";

View file

@ -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>

View file

@ -0,0 +1,222 @@
/** @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";
import { Component, useState } from "@odoo/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),
title: this.props.context.default_name,
};
}
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";

View file

@ -0,0 +1,240 @@
// 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;
background-color: $o-view-background-color;
&,
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;
}
}

View file

@ -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>

View file

@ -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;
}
}
}
}

View file

@ -0,0 +1,829 @@
/** @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 || 0) % 7,
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();
}
// Prevent picking a scale that is not supported by the view
if (!this.meta.scales.includes(this.meta.scale)) {
this.meta.scale = this.meta.scales[0];
}
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 currentWeekOffset = (start.weekday - firstDayOfWeek + 7) % 7;
start = start.minus({ days: currentWeekOffset });
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),
],{
context: {
'employee_id': this.employeeId,
}
});
}
/**
* @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, isTimeHidden, scale } = this.meta;
const startType = fields[fieldMapping.date_start].type;
const isAllDay =
startType === "date" ||
(fieldMapping.all_day && rawRecord[fieldMapping.all_day]) ||
false;
let start = isAllDay
? 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 = isAllDay
? deserializeDate(rawRecord[fieldMapping.date_stop])
: deserializeDateTime(rawRecord[fieldMapping.date_stop]);
}
const duration = rawRecord[fieldMapping.date_delay] || 1;
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 !== "year" &&
startType !== "date" &&
start.day === end.day;
const colorValue = rawRecord[fieldMapping.color];
const 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
*/
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 { filterFieldName, writeFieldName, writeResModel } = filterInfo;
const fields = [writeFieldName, filterFieldName].filter(Boolean);
const rawFilters = await this.fetchFilters(writeResModel, fields);
const previousFilters = previousSection ? previousSection.filters : [];
const filters = rawFilters.map((rawFilter) => {
const previousRecordFilter = previousFilters.find(
(f) => f.type === "record" && f.recordId === rawFilter.id
);
return this.makeFilterRecord(filterInfo, previousRecordFilter, rawFilter);
});
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, rawFilters)
);
}
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: writeFieldName,
model: writeResModel,
},
canCollapse: filters.length > 2,
canAddFilter: !!filterInfo.writeResModel,
};
}
/**
* @protected
*/
async loadDynamicFilters(data, filtersInfo) {
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 rawFilters = Object.values(data.records).reduce((filters, record) => {
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 (!filters.find((f) => f.id === value)) {
filters.push({
id: value,
[fieldName]: rawValue,
colorIndex: record.colorIndex,
});
}
}
return filters;
}, []);
const { colorFieldName } = filterInfo;
const shouldFetchColor =
colorFieldName &&
(!fieldMapping.color ||
`${fieldName}.${colorFieldName}` !== fields[fieldMapping.color].related);
let rawColors = [];
if (shouldFetchColor) {
const relatedIds = rawFilters.map(({ id }) => id);
if (relatedIds.length) {
rawColors = await this.orm.searchRead(
field.relation,
[["id", "in", relatedIds]],
[colorFieldName]
);
}
}
const filters = rawFilters.map((rawFilter) => {
const previousDynamicFilter = previousFilters.find(
(f) => f.type === "dynamic" && f.value === rawFilter.id
);
return this.makeFilterDynamic(
filterInfo,
previousDynamicFilter,
fieldName,
rawFilter,
rawColors
);
});
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, fieldName, rawFilter, rawColors) {
const { fieldMapping, fields } = this.meta;
const rawValue = rawFilter[fieldName];
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
const field = fields[fieldName];
const formatter = registry.category("formatters").get(field.type);
const { colorFieldName } = filterInfo;
const colorField = fields[fieldMapping.color];
const hasFilterColorAttr = !!colorFieldName;
const sameRelatedModel =
colorField &&
(colorField.relation === field.relation ||
(colorField.related && colorField.related.startsWith(`${fieldName}.`)));
let colorIndex = null;
if (hasFilterColorAttr || sameRelatedModel) {
colorIndex = rawFilter.colorIndex;
}
if (rawColors.length) {
const rawColor = rawColors.find(({ id }) => id === value);
colorIndex = rawColor ? rawColor[colorFieldName] : 0;
}
return {
type: "dynamic",
recordId: null,
value,
label: formatter(rawValue, { field }) || _t("Undefined"),
active: previousFilter ? previousFilter.active : true,
canRemove: false,
colorIndex,
hasAvatar: !!value,
};
}
/**
* @protected
*/
makeFilterRecord(filterInfo, previousFilter, rawRecord) {
const { colorFieldName, filterFieldName, writeFieldName } = filterInfo;
const { fields, fieldMapping } = this.meta;
const raw = rawRecord[writeFieldName];
const value = Array.isArray(raw) ? raw[0] : raw;
const field = fields[writeFieldName];
const isX2Many = ["many2many", "one2many"].includes(field.type);
const formatter = registry.category("formatters").get(isX2Many ? "many2one" : field.type);
const colorField = fields[fieldMapping.color];
const colorValue =
colorField &&
(() => {
const sameRelatedModel = colorField.relation === field.relation;
const sameRelatedField =
colorField.related === `${writeFieldName}.${colorFieldName}`;
const shouldHaveColor = sameRelatedModel || sameRelatedField;
const colorToUse = raw ? value : rawRecord[fieldMapping.color];
return shouldHaveColor ? colorToUse : null;
})();
const colorIndex = Array.isArray(colorValue) ? colorValue[0] : colorValue;
let active = false;
if (previousFilter) {
active = previousFilter.active;
} else if (filterFieldName) {
active = rawRecord[filterFieldName];
}
return {
type: "record",
recordId: rawRecord.id,
value,
label: formatter(raw),
active,
canRemove: true,
colorIndex,
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];
let colorIndex = value;
const rawRecord = rawRecords.find((r) => r[filterInfo.writeFieldName][0] === value);
if (filterInfo.colorFieldName && rawRecord) {
const colorValue = rawRecord[filterInfo.colorFieldName];
colorIndex = Array.isArray(colorValue) ? colorValue[0] : colorValue;
}
return {
type: "user",
recordId: null,
value,
label: this.user.name,
active: previousFilter ? previousFilter.active : true,
canRemove: false,
colorIndex,
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"];

View file

@ -0,0 +1,3 @@
.o_calendar_renderer .o_calendar_widget .o_calendar_disabled {
background-color: $gray-300;
}

View file

@ -0,0 +1,38 @@
/** @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";
import { Component } from "@odoo/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,
swipeInvalid: () => Boolean(document.querySelector(".o_event.fc-mirror")),
};
}
}
CalendarRenderer.components = {
day: CalendarCommonRenderer,
week: CalendarCommonRenderer,
month: CalendarCommonRenderer,
year: CalendarYearRenderer,
ActionSwiper,
};
CalendarRenderer.template = "web.CalendarRenderer";

View file

@ -0,0 +1,594 @@
.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"), $o-view-background-color);
.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 {
/*rtl:ignore*/
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 {
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();
}
&.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;
}
> 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;
}
}
}
}
}
}
// =============== Generate color classes ===============
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
.o_calendar_renderer .fc-view {
.o_calendar_color_#{$i - 1} {
border-color: $color;
&.fc-bgevent {
background-color: $color;
opacity: 0.5;
}
&.fc-event {
color: darken($color, 45%);
opacity: 1;
background: scale-color($color, $lightness: 50%);
.fc-bg {
background: scale-color($color, $lightness: 50%);
}
&.o_event_highlight {
opacity: 1;
.fc-content {
font-weight: bold;
}
}
&.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_event_hatched {
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba($color, 0.4) 10px,
rgba($color, 0.4) 20px
) !important;
.fc-bg {
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba($color, 0.5) 10px,
rgba($color, 0.5) 20px
);
}
}
&.o_event_striked {
background: linear-gradient(
transparent 0 45%,
$color 45% 55%,
transparent 55% 100%);
}
}
}
.o_cw_filter_color_#{$i - 1} {
.o_cw_filter_input_bg {
border-color: $color;
background: $color;
color: color-contrast($color);
}
}
.o_cw_popover.o_calendar_color_#{$i - 1} {
$color-subdle: mix($o-white, $color, 85%);
.card-header,
.card-header .popover-header {
background-color: $color-subdle;
color: color-contrast($color-subdle);
}
.card-header {
padding-left: 2px;
border-color: mix($card-border-color, mix($o-white, $color));
}
}
}

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -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);

View file

@ -0,0 +1,104 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { formatDate } from "@web/core/l10n/dates";
import { getColor } from "../colors";
import { getFormattedDateSpan } from '@web/views/calendar/utils';
import { Component } from "@odoo/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 = getFormattedDateSpan(start, end);
if (!(formattedDate in recordGroups)) {
recordGroups[formattedDate] = {
title: formattedDate,
start,
end,
records: [],
};
}
recordGroups[formattedDate].records.push(modifiedRecord);
}
return Object.values(recordGroups);
}
getRecordClass(record) {
const { colorIndex } = record;
const color = getColor(colorIndex);
if (color && typeof color === "number") {
return `o_calendar_color_${color}`;
}
return "";
}
getRecordStyle(record) {
const { colorIndex } = record;
const color = getColor(colorIndex);
if (color && typeof color === "string") {
return `background-color: ${color};`;
}
return "";
}
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();
});
}
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.subTemplates = {
popover: "web.CalendarYearPopover.popover",
body: "web.CalendarYearPopover.body",
footer: "web.CalendarYearPopover.footer",
record: "web.CalendarYearPopover.record",
};
CalendarYearPopover.props = {
close: Function,
date: true,
model: Object,
records: Array,
createRecord: Function,
deleteRecord: Function,
editRecord: Function,
};

View file

@ -0,0 +1,14 @@
.o_cw_popover_link {
border: solid 2px;
padding-left: 5px;
margin-top: 2px;
color: black;
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
&.o_calendar_color_#{$i - 1} {
border-color: $color;
background: $color;
}
}
}

View file

@ -0,0 +1,69 @@
<?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="{{ constructor.subTemplates.body }}" />
<t t-set-slot="footer">
<t t-call="{{ constructor.subTemplates.footer }}" />
</t>
</Dialog>
</t>
<t t-else="">
<t t-call="{{ constructor.subTemplates.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 popover-body">
<t t-call="{{ constructor.subTemplates.body }}" />
<div t-if="props.model.canCreate" class="mt-2">
<t t-call="{{ constructor.subTemplates.footer }}" />
</div>
</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="{{ constructor.subTemplates.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-style="{{ getRecordStyle(record) }}"
t-attf-class="o_cw_popover_link btn-link d-block {{ getRecordClass(record) }}"
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>

View file

@ -0,0 +1,191 @@
/** @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";
import { Component, useEffect, useRef, onRendered } from "@odoo/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: "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), "o_cw_popover");
}
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_0");
}
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";

View file

@ -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>

View file

@ -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 - 1) % 55) + 1);
} else {
colorMap.set(key, (((colorMap.size + 1) * 5) % 24) + 1);
}
return colorMap.get(key);
}

View file

@ -0,0 +1,104 @@
/** @odoo-module **/
import { Component, onMounted, onWillUnmount, useEffect, useRef, xml } from "@odoo/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,
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}"]`)
.filter((i, e) => {
return e.textContent.trim() === day.toString();
})
.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" />`;

View file

@ -0,0 +1,224 @@
/** @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";
import { getColor } from "../colors";
import { Component, useState } from "@odoo/owl";
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.addDialog = useOwnedDialogs();
this.orm = useService("orm");
this.popover = usePopover();
this.removePopover = null;
}
getFilterColor(filter) {
return filter.colorIndex !== null ? "o_cw_filter_color_" + getColor(filter.colorIndex) : "";
}
getAutoCompleteProps(section) {
return {
autoSelect: true,
resetOnSelect: true,
placeholder: `+ ${_t("Add")} ${section.label}`,
sources: [
{
placeholder: _t("Loading..."),
options: (request) => this.loadSource(section, request),
},
],
onSelect: (option, params = {}) => {
if (option.action) {
option.action(params);
return;
}
this.props.model.createFilter(section.fieldName, option.value);
},
value: "",
};
}
async loadSource(section, request) {
const resModel = this.props.model.fields[section.fieldName].relation;
const domain = [
["id", "not in", section.filters.filter((f) => f.type !== "all").map((f) => f.value)],
];
const records = await this.orm.call(resModel, "name_search", [], {
name: request,
operator: "ilike",
args: 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(section, resModel, domain, request),
});
}
if (records.length === 0) {
options.push({
label: _t("No records"),
classList: "o_m2o_no_result",
unselectable: true,
});
}
return options;
}
async onSearchMore(section, resModel, domain, request) {
const dynamicFilters = [];
if (request.length) {
const nameGets = await this.orm.call(resModel, "name_search", [], {
name: request,
args: domain,
operator: "ilike",
context: {},
});
dynamicFilters.push({
description: sprintf(_t("Quick search: %s"), request),
domain: [["id", "in", nameGets.map((nameGet) => nameGet[0])]],
});
}
const title = sprintf(_t("Search: %s"), section.label);
this.addDialog(SelectCreateDialog, {
title,
noCreate: true,
multiSelect: false,
resModel,
context: {},
domain,
onSelected: ([resId]) => this.props.model.createFilter(section.fieldName, resId),
dynamicFilters,
});
}
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 mw-25",
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 = {
AutoComplete,
Transition,
};
CalendarFilterPanel.template = "web.CalendarFilterPanel";
CalendarFilterPanel.subTemplates = {
filter: "web.CalendarFilterPanel.filter",
};

View file

@ -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;
}
}

View file

@ -0,0 +1,146 @@
<?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="{{ constructor.subTemplates.filter }}" />
</t>
</div>
</Transition>
<t t-if="section.canAddFilter">
<AutoComplete 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="getFilterColor(filter)"
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 text-truncate"
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>
</templates>

View file

@ -0,0 +1,143 @@
/** @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";
import {
onMounted,
onPatched,
onWillStart,
onWillUnmount,
useComponent,
useExternalListener,
useRef,
} from "@odoo/owl";
export function useCalendarPopover(component) {
const owner = useComponent();
const popover = usePopover();
const dialog = useService("dialog");
let remove = null;
let fcPopover;
useExternalListener(
window,
"mousedown",
(ev) => {
if (fcPopover) {
// do not let fullcalendar popover close when our own popover is open
ev.stopPropagation();
}
},
{ capture: true }
);
function cleanup() {
fcPopover = null;
remove = null;
}
function close() {
if (remove) {
remove();
}
cleanup();
}
return {
close,
open(target, props, popoverClass) {
close();
fcPopover = target.closest(".fc-popover");
if (owner.env.isSmall) {
remove = dialog.add(component, props, { onClose: cleanup });
} else {
remove = popover.add(target, component, props, {
popoverClass,
position: "right",
onClose: cleanup,
});
}
},
};
}
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;
},
};
}

View file

@ -0,0 +1,32 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { getColor } from "../colors";
export class CalendarMobileFilterPanel extends Component {
get caretDirection() {
return this.props.sideBarShown ? "down" : "left";
}
getFilterColor(filter) {
return `o_color_${getColor(filter.colorIndex)}`;
}
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";

View file

@ -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-att-class="getFilterColor(filter)"></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>

View file

@ -0,0 +1,87 @@
/** @odoo-module **/
import { useAutofocus, useService } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
import { _lt } from "@web/core/l10n/translation";
import { Component } from "@odoo/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 = {
title: { type: String, optional: true },
close: Function,
record: Object,
model: Object,
editRecord: Function,
};

View file

@ -0,0 +1,33 @@
<?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"
t-att-value="props.title"
/>
</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>

View file

@ -0,0 +1,14 @@
/** @odoo-module **/
export function getFormattedDateSpan(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");
}
}

View file

@ -0,0 +1,372 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { Dialog } from "@web/core/dialog/dialog";
import { editModelDebug } from "@web/core/debug/debug_utils";
import { formatDateTime, deserializeDateTime } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { formatMany2one } from "@web/views/fields/formatters";
import { evalDomain } from "@web/views/utils";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { Component, onWillStart, useState, xml } from "@odoo/owl";
import {serializeDate, serializeDateTime} from "../core/l10n/dates";
const debugRegistry = registry.category("debug");
function viewSeparator() {
return { type: "separator", sequence: 300 };
}
debugRegistry.category("view").add("viewSeparator", viewSeparator);
//------------------------------------------------------------------------------
// Get view
//------------------------------------------------------------------------------
class GetViewDialog extends Component {}
GetViewDialog.template = xml`
<Dialog title="this.constructor.title">
<pre t-esc="props.arch"/>
</Dialog>`;
GetViewDialog.components = { Dialog };
GetViewDialog.props = {
arch: { type: String },
close: { type: Function },
};
GetViewDialog.title = _lt("Get View");
export function getView({ component, env }) {
let { arch } = component.props;
if ("viewInfo" in component.props) {
//legacy
arch = component.props.viewInfo.arch;
}
return {
type: "item",
description: env._t("Get View"),
callback: () => {
env.services.dialog.add(GetViewDialog, { arch });
},
sequence: 340,
};
}
debugRegistry.category("view").add("getView", getView);
//------------------------------------------------------------------------------
// Edit View
//------------------------------------------------------------------------------
export function editView({ accessRights, component, env }) {
if (!accessRights.canEditView) {
return null;
}
let { viewId, viewType: type } = component.env.config || {}; // fallback is there for legacy
if ("viewInfo" in component.props) {
// legacy
viewId = component.props.viewInfo.view_id;
type = component.props.viewInfo.type;
type = type === "tree" ? "list" : type;
}
const displayName = type[0].toUpperCase() + type.slice(1);
const description = env._t("Edit View: ") + displayName;
return {
type: "item",
description,
callback: () => {
editModelDebug(env, description, "ir.ui.view", viewId);
},
sequence: 350,
};
}
debugRegistry.category("view").add("editView", editView);
//------------------------------------------------------------------------------
// Edit SearchView
//------------------------------------------------------------------------------
export function editSearchView({ accessRights, component, env }) {
if (!accessRights.canEditView) {
return null;
}
let { searchViewId } = component.props.info || {}; // fallback is there for legacy
if ("viewParams" in component.props) {
//legacy
if (!component.props.viewParams.action.controlPanelFieldsView) {
return null;
}
searchViewId = component.props.viewParams.action.controlPanelFieldsView.view_id;
}
if (searchViewId === undefined) {
return null;
}
const description = env._t("Edit SearchView");
return {
type: "item",
description,
callback: () => {
editModelDebug(env, description, "ir.ui.view", searchViewId);
},
sequence: 360,
};
}
debugRegistry.category("view").add("editSearchView", editSearchView);
// -----------------------------------------------------------------------------
// View Metadata
// -----------------------------------------------------------------------------
class GetMetadataDialog extends Component {
setup() {
this.orm = useService("orm");
this.dialogService = useService("dialog");
this.title = this.env._t("View Metadata");
this.state = useState({});
onWillStart(() => this.loadMetadata());
}
onClickCreateXmlid() {
const context = Object.assign({}, this.context, {
default_module: "__custom__",
default_res_id: this.state.id,
default_model: this.props.resModel,
});
this.dialogService.add(FormViewDialog, {
context,
onRecordSaved: () => this.loadMetadata(),
resModel: "ir.model.data",
});
}
async toggleNoupdate() {
await this.env.services.orm.call("ir.model.data", "toggle_noupdate", [
this.props.resModel,
this.state.id,
]);
await this.loadMetadata();
}
async loadMetadata() {
const args = [[this.props.resId]];
const result = await this.orm.call(this.props.resModel, "get_metadata", args);
const metadata = result[0];
this.state.id = metadata.id;
this.state.xmlid = metadata.xmlid;
this.state.xmlids = metadata.xmlids;
this.state.noupdate = metadata.noupdate;
this.state.creator = formatMany2one(metadata.create_uid);
this.state.lastModifiedBy = formatMany2one(metadata.write_uid);
this.state.createDate = formatDateTime(deserializeDateTime(metadata.create_date));
this.state.writeDate = formatDateTime(deserializeDateTime(metadata.write_date));
}
}
GetMetadataDialog.template = "web.DebugMenu.GetMetadataDialog";
GetMetadataDialog.components = { Dialog };
export function viewMetadata({ component, env }) {
const resId = component.model.root.resId;
if (!resId) {
return null; // No record
}
return {
type: "item",
description: env._t("View Metadata"),
callback: () => {
env.services.dialog.add(GetMetadataDialog, {
resModel: component.props.resModel,
resId,
});
},
sequence: 320,
};
}
debugRegistry.category("form").add("viewMetadata", viewMetadata);
// -----------------------------------------------------------------------------
// Set Defaults
// -----------------------------------------------------------------------------
class SetDefaultDialog extends Component {
setup() {
this.orm = useService("orm");
this.title = this.env._t("Set Defaults");
this.state = {
fieldToSet: "",
condition: "",
scope: "self",
};
const root = this.props.component.model.root;
this.fields = root.fields;
this.fieldsInfo = root.activeFields;
this.fieldNamesInView = root.fieldNames;
this.fieldNamesBlackList = ["message_attachment_count"];
this.fieldsValues = root.data;
this.modifierDatas = {};
this.fieldNamesInView.forEach((fieldName) => {
this.modifierDatas[fieldName] = this.fieldsInfo[fieldName].modifiers;
});
this.defaultFields = this.getDefaultFields();
this.conditions = this.getConditions();
}
getDefaultFields() {
return this.fieldNamesInView
.filter((fieldName) => !this.fieldNamesBlackList.includes(fieldName))
.map((fieldName) => {
const modifierData = this.modifierDatas[fieldName];
let invisibleOrReadOnly;
if (modifierData) {
const evalContext = this.props.component.model.root.evalContext;
invisibleOrReadOnly =
evalDomain(modifierData.invisible, evalContext) ||
evalDomain(modifierData.readonly, evalContext);
}
const fieldInfo = this.fields[fieldName];
const valueDisplayed = this.display(fieldInfo, this.fieldsValues[fieldName]);
const value = valueDisplayed[0];
const displayed = valueDisplayed[1];
// ignore fields which are empty, invisible, readonly, o2m or m2m
if (
!value ||
invisibleOrReadOnly ||
fieldInfo.type === "one2many" ||
fieldInfo.type === "many2many" ||
fieldInfo.type === "binary" ||
this.fieldsInfo[fieldName].options.isPassword
) {
return false;
}
return {
name: fieldName,
string: fieldInfo.string,
value,
displayed,
};
})
.filter((val) => val)
.sort((field) => field.string);
}
getConditions() {
return this.fieldNamesInView
.filter((fieldName) => {
const fieldInfo = this.fields[fieldName];
return fieldInfo.change_default;
})
.map((fieldName) => {
const fieldInfo = this.fields[fieldName];
const valueDisplayed = this.display(fieldInfo, this.fieldsValues[fieldName]);
const value = valueDisplayed[0];
const displayed = valueDisplayed[1];
return {
name: fieldName,
string: fieldInfo.string,
value: value,
displayed: displayed,
};
});
}
display(fieldInfo, value) {
let displayed = value;
if (value && fieldInfo.type === "many2one") {
displayed = value[1];
value = value[0];
} else if (value && fieldInfo.type === "selection") {
displayed = fieldInfo.selection.find((option) => {
return option[0] === value;
})[1];
}
if (
(typeof displayed === "string" || displayed instanceof String) &&
displayed.length > 60
) {
displayed = displayed.slice(0, 57) + "...";
}
return [value, displayed];
}
async saveDefault() {
if (!this.state.fieldToSet) {
return;
}
let fieldToSet = this.defaultFields.find((field) => {
return field.name === this.state.fieldToSet;
}).value;
if(fieldToSet.constructor.name.toLowerCase() === "date"){
fieldToSet = serializeDate(fieldToSet);
} else if (fieldToSet.constructor.name.toLowerCase() === "datetime"){
fieldToSet = serializeDateTime(fieldToSet);
}
await this.orm.call("ir.default", "set", [
this.props.resModel,
this.state.fieldToSet,
fieldToSet,
this.state.scope === "self",
true,
this.state.condition || false,
]);
this.props.close();
}
}
SetDefaultDialog.template = "web.DebugMenu.SetDefaultDialog";
SetDefaultDialog.components = { Dialog };
export function setDefaults({ component, env }) {
return {
type: "item",
description: env._t("Set Defaults"),
callback: () => {
env.services.dialog.add(SetDefaultDialog, {
resModel: component.props.resModel,
component,
});
},
sequence: 310,
};
}
debugRegistry.category("form").add("setDefaults", setDefaults);
//------------------------------------------------------------------------------
// Manage Attachments
//------------------------------------------------------------------------------
export function manageAttachments({ component, env }) {
const resId = component.model.root.resId;
if (!resId) {
return null; // No record
}
const description = env._t("Manage Attachments");
return {
type: "item",
description,
callback: () => {
env.services.action.doAction({
res_model: "ir.attachment",
name: description,
views: [
[false, "list"],
[false, "form"],
],
type: "ir.actions.act_window",
domain: [
["res_model", "=", component.props.resModel],
["res_id", "=", resId],
],
context: {
default_res_model: component.props.resModel,
default_res_id: resId,
},
});
},
sequence: 330,
};
}
debugRegistry.category("form").add("manageAttachments", manageAttachments);

View file

@ -0,0 +1,131 @@
/** @odoo-module **/
/* global ace */
import { loadJS } from "@web/core/assets";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { formatText } from "../formatters";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillStart, onWillUpdateProps, useEffect, useRef } from "@odoo/owl";
export class AceField extends Component {
setup() {
this.aceEditor = null;
this.editorRef = useRef("editor");
this.cookies = useService("cookie");
onWillStart(async () => {
await loadJS("/web/static/lib/ace/ace.js");
const jsLibs = [
"/web/static/lib/ace/mode-python.js",
"/web/static/lib/ace/mode-xml.js",
"/web/static/lib/ace/mode-qweb.js",
];
const proms = jsLibs.map((url) => loadJS(url));
return Promise.all(proms);
});
onWillUpdateProps(this.updateAce);
useEffect(
() => {
this.setupAce();
this.updateAce(this.props);
return () => this.destroyAce();
},
() => [this.editorRef.el]
);
useBus(this.env.bus, "RELATIONAL_MODEL:WILL_SAVE_URGENTLY", () => this.commitChanges());
useBus(this.env.bus, "RELATIONAL_MODEL:NEED_LOCAL_CHANGES", ({ detail }) =>
detail.proms.push(this.commitChanges())
);
}
get aceSession() {
return this.aceEditor.getSession();
}
setupAce() {
this.aceEditor = ace.edit(this.editorRef.el);
this.aceEditor.setOptions({
maxLines: Infinity,
showPrintMargin: false,
theme: this.cookies.current.color_scheme === "dark" ? "ace/theme/monokai" : "",
});
this.aceEditor.$blockScrolling = true;
this.aceSession.setOptions({
useWorker: false,
tabSize: 2,
useSoftTabs: true,
});
this.aceEditor.on("blur", this.commitChanges.bind(this));
}
updateAce({ mode, readonly, value }) {
if (!this.aceEditor) {
return;
}
this.aceSession.setOptions({
mode: `ace/mode/${mode === "xml" ? "qweb" : mode}`,
});
this.aceEditor.setOptions({
readOnly: readonly,
highlightActiveLine: !readonly,
highlightGutterLine: !readonly,
});
this.aceEditor.renderer.setOptions({
displayIndentGuides: !readonly,
showGutter: !readonly,
});
this.aceEditor.renderer.$cursorLayer.element.style.display = readonly ? "none" : "block";
const formattedValue = formatText(value);
if (this.aceSession.getValue() !== formattedValue) {
this.aceSession.setValue(formattedValue);
}
}
destroyAce() {
if (this.aceEditor) {
this.aceEditor.destroy();
}
}
commitChanges() {
if (!this.props.readonly) {
const value = this.aceSession.getValue();
if ((this.props.value || "") !== value) {
return this.props.update(value);
}
}
}
}
AceField.template = "web.AceField";
AceField.props = {
...standardFieldProps,
mode: { type: String, optional: true },
};
AceField.defaultProps = {
mode: "qweb",
};
AceField.displayName = _lt("Ace Editor");
AceField.supportedTypes = ["text"];
AceField.extractProps = ({ attrs }) => {
return {
mode: attrs.options.mode,
};
};
registry.category("fields").add("ace", AceField);

View file

@ -0,0 +1,3 @@
.o_field_ace {
display: block !important;
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.AceField" owl="1">
<!-- TODO check if some classes are useless -->
<div class="o_field_widget oe_form_field o_ace_view_editor oe_ace_open">
<div class="ace-view-editor" t-ref="editor" />
</div>
</t>
</templates>

View file

@ -0,0 +1,15 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
export class AttachmentImageField extends Component {}
AttachmentImageField.template = "web.AttachmentImageField";
AttachmentImageField.displayName = _lt("Attachment Image");
AttachmentImageField.supportedTypes = ["many2one"];
registry.category("fields").add("attachment_image", AttachmentImageField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.AttachmentImageField" owl="1">
<div class="o_attachment_image">
<img t-if="props.value" t-attf-src="/web/image/{{ props.value[0] }}?unique=1" t-att-title="props.value[1]" alt="Image" />
</div>
</t>
</templates>

View file

@ -0,0 +1,36 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
const formatters = registry.category("formatters");
export class BadgeField extends Component {
get formattedValue() {
const formatter = formatters.get(this.props.type);
return formatter(this.props.value, {
selection: this.props.record.fields[this.props.name].selection,
});
}
get classFromDecoration() {
for (const decorationName in this.props.decorations) {
if (this.props.decorations[decorationName]) {
return `text-bg-${decorationName}`;
}
}
return "";
}
}
BadgeField.template = "web.BadgeField";
BadgeField.props = {
...standardFieldProps,
};
BadgeField.displayName = _lt("Badge");
BadgeField.supportedTypes = ["selection", "many2one", "char"];
registry.category("fields").add("badge", BadgeField);

View file

@ -0,0 +1,14 @@
// TODO: remove second selector when we remove legacy badge field
.o_field_badge span, span.o_field_badge {
border: 0;
font-size: 12px;
user-select: none;
background-color: rgba(lightgray, 0.5);
font-weight: 500;
@include o-text-overflow;
transition: none; // remove transition to prevent badges from flickering at reload
color: #444B5A;
&.o_field_empty {
display: none !important;
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.BadgeField" owl="1">
<span t-if="props.value" class="badge rounded-pill" t-att-class="classFromDecoration" t-esc="formattedValue" />
</t>
</templates>

View file

@ -0,0 +1,91 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class BadgeSelectionField extends Component {
get options() {
switch (this.props.record.fields[this.props.name].type) {
case "many2one":
// WOWL: conversion needed while we keep using the legacy model
return Object.values(this.props.record.preloadedData[this.props.name]).map((v) => {
return [v.id, v.display_name];
});
case "selection":
return this.props.record.fields[this.props.name].selection;
default:
return [];
}
}
get string() {
switch (this.props.type) {
case "many2one":
return this.props.value ? this.props.value[1] : "";
case "selection":
return this.props.value !== false
? this.options.find((o) => o[0] === this.props.value)[1]
: "";
default:
return "";
}
}
get value() {
const rawValue = this.props.value;
return this.props.type === "many2one" && rawValue ? rawValue[0] : rawValue;
}
stringify(value) {
return JSON.stringify(value);
}
/**
* @param {string | number | false} value
*/
onChange(value) {
switch (this.props.type) {
case "many2one":
if (value === false) {
this.props.update(false);
} else {
this.props.update(this.options.find((option) => option[0] === value));
}
break;
case "selection":
if (value === this.props.value) {
this.props.update(false);
} else {
this.props.update(value);
}
break;
}
}
}
BadgeSelectionField.template = "web.BadgeSelectionField";
BadgeSelectionField.props = {
...standardFieldProps,
};
BadgeSelectionField.displayName = _lt("Badges");
BadgeSelectionField.supportedTypes = ["many2one", "selection"];
BadgeSelectionField.legacySpecialData = "_fetchSpecialMany2ones";
BadgeSelectionField.isEmpty = (record, fieldName) => record.data[fieldName] === false;
registry.category("fields").add("selection_badge", BadgeSelectionField);
export function preloadSelection(orm, record, fieldName) {
const field = record.fields[fieldName];
const context = record.evalContext;
const domain = record.getFieldDomain(fieldName).toList(context);
return orm.call(field.relation, "name_search", ["", domain]);
}
registry.category("preloadedData").add("selection_badge", {
loadOnTypes: ["many2one"],
preload: preloadSelection,
});

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BadgeSelectionField" owl="1">
<t t-if="props.readonly">
<span t-esc="string" t-att-raw-value="value" />
</t>
<t t-else="">
<t t-foreach="options" t-as="option" t-key="option[0]">
<span
class="o_selection_badge"
t-att-class="{ active: value === option[0] }"
t-att-value="stringify(option[0])"
t-esc="option[1]"
t-on-click="() => this.onChange(option[0])"
/>
</t>
</t>
</t>
</templates>

View file

@ -0,0 +1,81 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { isBinarySize, toBase64Length } from "@web/core/utils/binary";
import { download } from "@web/core/network/download";
import { standardFieldProps } from "../standard_field_props";
import { FileUploader } from "../file_handler";
import { _lt } from "@web/core/l10n/translation";
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
export const MAX_FILENAME_SIZE_BYTES = 0xFF; // filenames do not exceed 255 bytes on Linux/Windows/MacOS
export class BinaryField extends Component {
setup() {
this.notification = useService("notification");
this.state = useState({
fileName: this.props.record.data[this.props.fileNameField] || "",
});
onWillUpdateProps((nextProps) => {
this.state.fileName = nextProps.record.data[nextProps.fileNameField] || "";
});
}
get fileName() {
let value = this.props.value;
value = value && typeof value === "string" ? value : false;
return (this.state.fileName || value || "").slice(0, toBase64Length(MAX_FILENAME_SIZE_BYTES));
}
update({ data, name }) {
this.state.fileName = name || "";
const { fileNameField, record } = this.props;
const changes = { [this.props.name]: data || false };
if (fileNameField in record.fields && record.data[fileNameField] !== name) {
changes[fileNameField] = name || false;
}
return this.props.record.update(changes);
}
async onFileDownload() {
await download({
data: {
model: this.props.record.resModel,
id: this.props.record.resId,
field: this.props.name,
filename_field: this.fileName,
filename: this.fileName || "",
download: true,
data: isBinarySize(this.props.value) ? null : this.props.value,
},
url: "/web/content",
});
}
}
BinaryField.template = "web.BinaryField";
BinaryField.components = {
FileUploader,
};
BinaryField.props = {
...standardFieldProps,
acceptedFileExtensions: { type: String, optional: true },
fileNameField: { type: String, optional: true },
};
BinaryField.defaultProps = {
acceptedFileExtensions: "*",
};
BinaryField.displayName = _lt("File");
BinaryField.supportedTypes = ["binary"];
BinaryField.extractProps = ({ attrs }) => {
return {
acceptedFileExtensions: attrs.options.accepted_file_extensions,
fileNameField: attrs.filename,
};
};
registry.category("fields").add("binary", BinaryField);

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BinaryField" owl="1">
<t t-if="!props.readonly">
<t t-if="props.value">
<div class="w-100 d-inline-flex">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
file="{ data: props.value, name: fileName }"
onUploaded.bind="update"
>
<t t-if="props.record.resId and !props.record.isDirty">
<button
class="btn btn-secondary fa fa-download"
data-tooltip="Download"
aria-label="Download"
t-on-click="onFileDownload"
/>
</t>
<t t-set-slot="toggler">
<input type="text" class="o_input" t-att-value="fileName" readonly="readonly" />
<button
class="btn btn-secondary fa fa-pencil o_select_file_button"
data-tooltip="Edit"
aria-label="Edit"
/>
</t>
<button
class="btn btn-secondary fa fa-trash o_clear_file_button"
data-tooltip="Clear"
aria-label="Clear"
t-on-click="() => this.update({})"
/>
</FileUploader>
</div>
</t>
<t t-else="">
<label class="o_select_file_button btn btn-primary">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
onUploaded.bind="update"
>
<t t-set-slot="toggler">
Upload your file
</t>
</FileUploader>
</label>
</t>
</t>
<t t-elif="props.record.resId and props.value">
<a class="o_form_uri" href="#" t-on-click.prevent="onFileDownload">
<span class="fa fa-download me-2" />
<t t-if="state.fileName" t-esc="state.fileName" />
</a>
</t>
</t>
</templates>

View file

@ -0,0 +1,34 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { Component } from "@odoo/owl";
export class BooleanField extends Component {
get isReadonly() {
return !(this.props.record.isInEdition && !this.props.record.isReadonly(this.props.name));
}
/**
* @param {boolean} newValue
*/
onChange(newValue) {
this.props.update(newValue);
}
}
BooleanField.template = "web.BooleanField";
BooleanField.components = { CheckBox };
BooleanField.props = {
...standardFieldProps,
};
BooleanField.displayName = _lt("Checkbox");
BooleanField.supportedTypes = ["boolean"];
BooleanField.isEmpty = () => false;
registry.category("fields").add("boolean", BooleanField);

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BooleanField" owl="1">
<CheckBox id="props.id" value="props.value or false" className="'d-inline-block'" disabled="isReadonly" onChange.bind="onChange" />
</t>
</templates>

View file

@ -0,0 +1,31 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { archParseBoolean } from "@web/views/utils";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class BooleanFavoriteField extends Component {}
BooleanFavoriteField.template = "web.BooleanFavoriteField";
BooleanFavoriteField.props = {
...standardFieldProps,
noLabel: { type: Boolean, optional: true },
};
BooleanFavoriteField.defaultProps = {
noLabel: false,
};
BooleanFavoriteField.displayName = _lt("Favorite");
BooleanFavoriteField.supportedTypes = ["boolean"];
BooleanFavoriteField.isEmpty = () => false;
BooleanFavoriteField.extractProps = ({ attrs }) => {
return {
noLabel: archParseBoolean(attrs.nolabel),
};
};
registry.category("fields").add("boolean_favorite", BooleanFavoriteField);

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BooleanFavoriteField" owl="1">
<div class="o_favorite" t-on-click.prevent.stop="() => props.update(!props.value)">
<a href="#">
<i
class="fa"
role="img"
t-att-class="props.value ? 'fa-star' : 'fa-star-o'"
t-att-title="props.value ? 'Remove from Favorites' : 'Add to Favorites'"
t-att-aria-label="props.value ? 'Remove from Favorites' : 'Add to Favorites'"
/>
<t t-if="!props.noLabel"> <t t-esc="props.value ? 'Remove from Favorites' : 'Add to Favorites'" /></t>
</a>
</div>
</t>
</templates>

View file

@ -0,0 +1,29 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { BooleanField } from "../boolean/boolean_field";
export class BooleanToggleField extends BooleanField {
get isReadonly() {
return this.props.record.isReadonly(this.props.name);
}
onChange(newValue) {
this.props.update(newValue, { save: this.props.autosave });
}
}
BooleanToggleField.template = "web.BooleanToggleField";
BooleanToggleField.displayName = _lt("Toggle");
BooleanToggleField.props = {
...BooleanField.props,
autosave: { type: Boolean, optional: true },
};
BooleanToggleField.extractProps = ({ attrs }) => {
return {
autosave: "autosave" in attrs.options ? Boolean(attrs.options.autosave) : true,
};
};
registry.category("fields").add("boolean_toggle", BooleanToggleField);

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BooleanToggleField" t-inherit="web.BooleanField" t-inherit-mode="primary" owl="1">
<xpath expr="//CheckBox" position="attributes">
<attribute name="className">'o_field_boolean o_boolean_toggle form-switch'</attribute>
</xpath>
<xpath expr="//CheckBox" position="inside">
&#8203; <!-- Zero width space needed to set height -->
</xpath>
</t>
</templates>

View file

@ -0,0 +1,16 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { BooleanToggleField } from "./boolean_toggle_field";
export class ListBooleanToggleField extends BooleanToggleField {
onClick() {
if (!this.props.readonly) {
this.props.update(!this.props.value, { save: this.props.autosave });
}
}
}
ListBooleanToggleField.template = "web.ListBooleanToggleField";
registry.category("fields").add("list.boolean_toggle", ListBooleanToggleField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ListBooleanToggleField" owl="1">
<div t-on-click="onClick">
<t t-call="web.BooleanToggleField" />
</div>
</t>
</templates>

View file

@ -0,0 +1,106 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { archParseBoolean } from "@web/views/utils";
import { formatChar } from "../formatters";
import { useInputField } from "../input_field_hook";
import { standardFieldProps } from "../standard_field_props";
import { TranslationButton } from "../translation_button";
import { useDynamicPlaceholder } from "../dynamicplaceholder_hook";
import { Component, onMounted, onWillUnmount, useRef } from "@odoo/owl";
export class CharField extends Component {
setup() {
if (this.props.dynamicPlaceholder) {
this.dynamicPlaceholder = useDynamicPlaceholder();
}
this.input = useRef("input");
useInputField({ getValue: () => this.props.value || "", parse: (v) => this.parse(v) });
onMounted(this.onMounted);
onWillUnmount(this.onWillUnmount);
}
async onKeydownListener(ev) {
if (ev.key === this.dynamicPlaceholder.TRIGGER_KEY && ev.target === this.input.el) {
const baseModel = this.props.record.data.mailing_model_real;
if (baseModel) {
await this.dynamicPlaceholder.open(
this.input.el,
baseModel,
{
validateCallback: this.onDynamicPlaceholderValidate.bind(this),
closeCallback: this.onDynamicPlaceholderClose.bind(this)
}
);
}
}
}
onMounted() {
if (this.props.dynamicPlaceholder) {
this.keydownListenerCallback = this.onKeydownListener.bind(this);
document.addEventListener('keydown', this.keydownListenerCallback);
}
}
onWillUnmount() {
if (this.props.dynamicPlaceholder) {
document.removeEventListener('keydown', this.keydownListenerCallback);
}
}
onDynamicPlaceholderValidate(chain, defaultValue) {
if (chain) {
const triggerKeyReplaceRegex = new RegExp(`${this.dynamicPlaceholder.TRIGGER_KEY}$`);
let dynamicPlaceholder = "{{object." + chain.join('.');
dynamicPlaceholder += defaultValue && defaultValue !== '' ? ` or '''${defaultValue}'''}}` : '}}';
this.props.update(this.input.el.value.replace(triggerKeyReplaceRegex, '') + dynamicPlaceholder);
}
}
onDynamicPlaceholderClose() {
this.input.el.focus();
}
get formattedValue() {
return formatChar(this.props.value, { isPassword: this.props.isPassword });
}
parse(value) {
if (this.props.shouldTrim) {
return value.trim();
}
return value;
}
}
CharField.template = "web.CharField";
CharField.components = {
TranslationButton,
};
CharField.defaultProps = { dynamicPlaceholder: false };
CharField.props = {
...standardFieldProps,
autocomplete: { type: String, optional: true },
isPassword: { type: Boolean, optional: true },
placeholder: { type: String, optional: true },
dynamicPlaceholder: { type: Boolean, optional: true},
shouldTrim: { type: Boolean, optional: true },
maxLength: { type: Number, optional: true },
isTranslatable: { type: Boolean, optional: true },
};
CharField.displayName = _lt("Text");
CharField.supportedTypes = ["char"];
CharField.extractProps = ({ attrs, field }) => {
return {
shouldTrim: field.trim && !archParseBoolean(attrs.password), // passwords shouldn't be trimmed
maxLength: field.size,
isTranslatable: field.translate,
dynamicPlaceholder: attrs.options.dynamic_placeholder,
autocomplete: attrs.autocomplete,
isPassword: archParseBoolean(attrs.password),
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("char", CharField);

View file

@ -0,0 +1,7 @@
// Heading element normally takes the full width of the parent,
// so the char_field wrapper should take the same width.
@include media-breakpoint-up(md) {
.o_field_char {
width: inherit;
}
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CharField" owl="1">
<t t-if="props.readonly">
<span t-esc="formattedValue" />
</t>
<t t-else="">
<input
class="o_input"
t-att-class="{'o_field_translate': props.isTranslatable}"
t-att-id="props.id"
t-att-type="props.isPassword ? 'password' : 'text'"
t-att-autocomplete="props.autocomplete or (props.isPassword ? 'new-password' : 'off')"
t-att-maxlength="props.maxLength > 0 and props.maxLength"
t-att-placeholder="props.placeholder"
t-ref="input"
/>
<t t-if="props.isTranslatable">
<TranslationButton
fieldName="props.name"
record="props.record"
/>
</t>
</t>
</t>
</templates>

View file

@ -0,0 +1,31 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component, useState, onWillUpdateProps } from "@odoo/owl";
export class ColorField extends Component {
setup() {
this.state = useState({
color: this.props.value || '',
});
onWillUpdateProps((nextProps) => {
this.state.color = nextProps.value || '';
});
}
get isReadonly() {
return this.props.record.isReadonly(this.props.name);
}
}
ColorField.template = "web.ColorField";
ColorField.props = {
...standardFieldProps,
};
ColorField.supportedTypes = ["char"];
registry.category("fields").add("color", ColorField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ColorField" owl="1">
<div class="o_field_color d-flex" t-att-class="{ 'o_field_cursor_disabled': isReadonly }" t-attf-style="background: #{state.color or 'url(/web/static/img/transparent.png)'}">
<input t-on-click.stop="" class="w-100 h-100 opacity-0" type="color" t-att-value="state.color" t-att-disabled="isReadonly" t-on-input="(ev) => this.state.color = ev.target.value" t-on-change="(ev) => this.props.update(ev.target.value)" />
</div>
</t>
</templates>

View file

@ -0,0 +1,34 @@
/** @odoo-module **/
import { ColorList } from "@web/core/colorlist/colorlist";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class ColorPickerField extends Component {
get canToggle() {
return this.props.record.activeFields[this.props.name].viewType !== "list";
}
get isExpanded() {
return !this.canToggle && !this.props.readonly;
}
switchColor(colorIndex) {
this.props.update(colorIndex);
}
}
ColorPickerField.template = "web.ColorPickerField";
ColorPickerField.components = {
ColorList,
};
ColorPickerField.props = {
...standardFieldProps,
};
ColorPickerField.supportedTypes = ["integer"];
ColorPickerField.RECORD_COLORS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
registry.category("fields").add("color_picker", ColorPickerField);

View file

@ -0,0 +1,14 @@
.o_field_widget.o_field_color_picker > div {
// to compensate on the 2px margin used to space the elements relative to each other
// we keep the top one cause the widget is a bit to high anyway
margin-left: -2px;
margin-right: -2px;
margin-bottom: -2px;
> div {
display: inline-block;
border: 1px solid white;
box-shadow: 0 0 0 1px map-get($grays, '300');
margin: 2px;
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ColorPickerField" owl="1">
<ColorList canToggle="canToggle" colors="constructor.RECORD_COLORS" forceExpanded="isExpanded" onColorSelected.bind="switchColor" selectedColor="props.value || 0"/>
</t>
</templates>

View file

@ -0,0 +1,49 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { Tooltip } from "@web/core/tooltip/tooltip";
import { useService } from "@web/core/utils/hooks";
import { Component, useRef } from "@odoo/owl";
export class CopyButton extends Component {
setup() {
this.button = useRef("button");
this.popover = useService("popover");
}
showTooltip() {
const closeTooltip = this.popover.add(this.button.el, Tooltip, {
tooltip: this.props.successText,
});
browser.setTimeout(() => {
closeTooltip();
}, 800);
}
async onClick() {
if (!browser.navigator.clipboard) {
return browser.console.warn("This browser doesn't allow to copy to clipboard");
}
let write;
// any kind of content can be copied into the clipboard using
// the appropriate native methods
if (typeof this.props.content === "string" || this.props.content instanceof String) {
write = (value) => browser.navigator.clipboard.writeText(value);
} else {
write = (value) => browser.navigator.clipboard.write(value);
}
try {
await write(this.props.content);
} catch(error) {
return browser.console.warn(error);
}
this.showTooltip();
}
}
CopyButton.template = "web.CopyButton";
CopyButton.props = {
className: { type: String, optional: true },
copyText: { type: String, optional: true },
successText: { type: String, optional: true },
content: { type: [String, Object], optional: true },
};

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CopyButton" owl="1">
<button
class="text-nowrap"
t-ref="button"
t-attf-class="btn btn-sm btn-primary o_clipboard_button {{ props.className || '' }}"
t-on-click.stop="onClick"
>
<span class="fa fa-clipboard mx-1"/>
<span t-esc="props.copyText"/>
</button>
</t>
</templates>

View file

@ -0,0 +1,60 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { CopyButton } from "./copy_button";
import { UrlField } from "../url/url_field";
import { CharField } from "../char/char_field";
import { TextField } from "../text/text_field";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
class CopyClipboardField extends Component {
setup() {
this.copyText = this.env._t("Copy");
this.successText = this.env._t("Copied");
}
get copyButtonClassName() {
return `o_btn_${this.props.type}_copy`;
}
}
CopyClipboardField.template = "web.CopyClipboardField";
CopyClipboardField.props = {
...standardFieldProps,
};
export class CopyClipboardButtonField extends CopyClipboardField {
get copyButtonClassName() {
const classNames = [super.copyButtonClassName];
classNames.push("rounded-2");
return classNames.join(" ");
}
}
CopyClipboardButtonField.template = "web.CopyClipboardButtonField";
CopyClipboardButtonField.components = { CopyButton };
CopyClipboardButtonField.displayName = _lt("Copy to Clipboard");
registry.category("fields").add("CopyClipboardButton", CopyClipboardButtonField);
export class CopyClipboardCharField extends CopyClipboardField {}
CopyClipboardCharField.components = { Field: CharField, CopyButton };
CopyClipboardCharField.displayName = _lt("Copy Text to Clipboard");
CopyClipboardCharField.supportedTypes = ["char"];
registry.category("fields").add("CopyClipboardChar", CopyClipboardCharField);
export class CopyClipboardTextField extends CopyClipboardField {}
CopyClipboardTextField.components = { Field: TextField, CopyButton };
CopyClipboardTextField.displayName = _lt("Copy Multiline Text to Clipboard");
CopyClipboardTextField.supportedTypes = ["text"];
registry.category("fields").add("CopyClipboardText", CopyClipboardTextField);
export class CopyClipboardURLField extends CopyClipboardField {}
CopyClipboardURLField.components = { Field: UrlField, CopyButton };
CopyClipboardURLField.displayName = _lt("Copy URL to Clipboard");
CopyClipboardURLField.supportedTypes = ["char"];
registry.category("fields").add("CopyClipboardURL", CopyClipboardURLField);

View file

@ -0,0 +1,22 @@
.o_field_CopyClipboardText, .o_field_CopyClipboardURL, .o_field_CopyClipboardChar {
> div {
grid-template-columns: auto min-content;
border: 1px solid $primary;
font-size: $font-size-sm;
color: $o-brand-primary;
font-weight: $badge-font-weight;
> span:first-child, a {
margin-left: 4px;
margin-right: 4px;
align-self: center;
text-align: center;
&:not(.o_field_CopyClipboardText) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CopyClipboardField" owl="1">
<div class="d-grid rounded-2 overflow-hidden">
<Field t-props="props"/>
<CopyButton t-if="props.value" className="copyButtonClassName" content="props.value" copyText="copyText" successText="successText"/>
</div>
</t>
<t t-name="web.CopyClipboardButtonField" owl="1">
<CopyButton t-if="props.value" className="copyButtonClassName" content="props.value" copyText="copyText" successText="successText"/>
</t>
</templates>

View file

@ -0,0 +1,72 @@
/** @odoo-module **/
import { DatePicker } from "@web/core/datepicker/datepicker";
import { areDateEquals, formatDate, formatDateTime } from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class DateField extends Component {
setup() {
/**
* The last value that has been commited to the model.
* Not changed in case of invalid field value.
*/
this.lastSetValue = null;
this.revId = 0;
}
get isDateTime() {
return this.props.record.fields[this.props.name].type === "datetime";
}
get date() {
return this.props.value && this.props.value.startOf("day");
}
get formattedValue() {
return this.isDateTime
? formatDateTime(this.props.value, { format: localization.dateFormat })
: formatDate(this.props.value);
}
onDateTimeChanged(date) {
if (!areDateEquals(this.date || "", date)) {
this.revId++;
this.props.update(date);
}
}
onDatePickerInput(ev) {
this.props.setDirty(ev.target.value !== this.lastSetValue);
}
onUpdateInput(date) {
this.props.setDirty(false);
this.lastSetValue = date;
}
}
DateField.template = "web.DateField";
DateField.components = {
DatePicker,
};
DateField.props = {
...standardFieldProps,
pickerOptions: { type: Object, optional: true },
placeholder: { type: String, optional: true },
};
DateField.defaultProps = {
pickerOptions: {},
};
DateField.displayName = _lt("Date");
DateField.supportedTypes = ["date", "datetime"];
DateField.extractProps = ({ attrs }) => {
return {
pickerOptions: attrs.options.datepicker,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("date", DateField);

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateField" owl="1">
<t t-if="props.readonly">
<span t-esc="formattedValue" />
</t>
<t t-else="">
<DatePicker
t-props="props.pickerOptions"
date="date"
inputId="props.id"
placeholder="props.placeholder"
onDateTimeChanged="(date) => this.onDateTimeChanged(date)"
onInput.bind="onDatePickerInput"
onUpdateInput.bind="onUpdateInput"
revId="revId"
/>
</t>
</t>
</templates>

View file

@ -0,0 +1,173 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { loadJS } from "@web/core/assets";
import { luxonToMoment, momentToLuxon } from "@web/core/l10n/dates";
import { useService } from "@web/core/utils/hooks";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillStart, useExternalListener, useRef, useEffect } from "@odoo/owl";
const formatters = registry.category("formatters");
const parsers = registry.category("parsers");
export class DateRangeField extends Component {
setup() {
this.notification = useService("notification");
this.root = useRef("root");
this.isPickerShown = false;
this.pickerContainer;
useExternalListener(window, "scroll", this.onWindowScroll, { capture: true });
onWillStart(() => loadJS("/web/static/lib/daterangepicker/daterangepicker.js"));
useEffect(
(el) => {
if (el) {
window.$(el).daterangepicker({
timePicker: this.isDateTime,
timePicker24Hour: true,
timePickerIncrement: 5,
autoUpdateInput: false,
locale: {
applyLabel: this.env._t("Apply"),
cancelLabel: this.env._t("Cancel"),
},
startDate: this.startDate ? luxonToMoment(this.startDate) : window.moment(),
endDate: this.endDate ? luxonToMoment(this.endDate) : window.moment(),
drops: "auto",
});
this.pickerContainer = window.$(el).data("daterangepicker").container[0];
window.$(el).on("apply.daterangepicker", this.onPickerApply.bind(this));
window.$(el).on("show.daterangepicker", this.onPickerShow.bind(this));
window.$(el).on("hide.daterangepicker", this.onPickerHide.bind(this));
this.pickerContainer.addEventListener(
"click",
(ev) => {
ev.isFromDateRangePicker = true;
},
{ capture: true }
);
this.pickerContainer.dataset.name = this.props.name;
}
return () => {
if (el) {
this.pickerContainer.remove();
}
};
},
() => [this.root.el, this.props.value]
);
}
get isDateTime() {
return this.props.formatType === "datetime";
}
get formattedValue() {
return this.formatValue(this.props.formatType, this.props.value);
}
get formattedEndDate() {
return this.formatValue(this.props.formatType, this.endDate);
}
get formattedStartDate() {
return this.formatValue(this.props.formatType, this.startDate);
}
get startDate() {
return this.props.record.data[this.props.relatedStartDateField || this.props.name];
}
get endDate() {
return this.props.record.data[this.props.relatedEndDateField || this.props.name];
}
get relatedDateRangeField() {
return this.props.relatedStartDateField
? this.props.relatedStartDateField
: this.props.relatedEndDateField;
}
formatValue(format, value) {
const formatter = formatters.get(format);
let formattedValue;
try {
formattedValue = formatter(value);
} catch {
this.props.record.setInvalidField(this.props.name);
}
return formattedValue;
}
updateRange(start, end) {
return this.props.record.update({
[this.props.relatedStartDateField || this.props.name]: start,
[this.props.relatedEndDateField || this.props.name]: end,
});
}
onChangeInput(ev) {
const parse = parsers.get(this.props.formatType);
let value;
try {
value = parse(ev.target.value);
} catch {
this.props.record.setInvalidField(this.props.name);
return;
}
this.props.update(value);
}
onWindowScroll(ev) {
const target = ev.target;
if (
this.isPickerShown &&
!this.env.isSmall &&
(target === window || !this.pickerContainer.contains(target))
) {
window.$(this.root.el).data("daterangepicker").hide();
}
}
async onPickerApply(ev, picker) {
const start = this.isDateTime ? picker.startDate : picker.startDate.startOf("day");
const end = this.isDateTime ? picker.endDate : picker.endDate.startOf("day");
const dates = [start, end].map(momentToLuxon);
await this.updateRange(dates[0], dates[1]);
const input = document.querySelector(
`.o_field_daterange[name='${this.relatedDateRangeField}'] input`
);
if (!input) {
// Don't attempt to update the related daterange field if not present in the DOM
return;
}
const target = window.$(input).data("daterangepicker");
target.setStartDate(picker.startDate);
target.setEndDate(picker.endDate);
}
onPickerShow() {
this.isPickerShown = true;
}
onPickerHide() {
this.isPickerShown = false;
}
}
DateRangeField.template = "web.DateRangeField";
DateRangeField.props = {
...standardFieldProps,
relatedEndDateField: { type: String, optional: true },
relatedStartDateField: { type: String, optional: true },
formatType: { type: String, optional: true },
placeholder: { type: String, optional: true },
};
DateRangeField.supportedTypes = ["date", "datetime"];
DateRangeField.extractProps = ({ attrs, field }) => {
return {
relatedEndDateField: attrs.options.related_end_date,
relatedStartDateField: attrs.options.related_start_date,
formatType: attrs.options.format_type || field.type,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("daterange", DateRangeField);

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateRangeField" owl="1">
<t t-if="props.readonly">
<t t-esc="formattedValue" />
</t>
<t t-else="">
<input class="o_input" style="width:100% !important" autocomplete="off" t-ref="root" type="text" t-att-id="props.id" t-att-value="formattedValue" t-att-placeholder="props.placeholder" t-on-change="onChangeInput" />
</t>
</t>
</templates>

View file

@ -0,0 +1,62 @@
/** @odoo-module **/
import { DateTimePicker } from "@web/core/datepicker/datepicker";
import { areDateEquals, formatDateTime } from "@web/core/l10n/dates";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class DateTimeField extends Component {
setup() {
/**
* The last value that has been commited to the model.
* Not changed in case of invalid field value.
*/
this.lastSetValue = null;
this.revId = 0;
}
get formattedValue() {
return formatDateTime(this.props.value);
}
onDateTimeChanged(date) {
if (!areDateEquals(this.props.value || "", date)) {
this.revId++;
this.props.update(date);
}
}
onDatePickerInput(ev) {
this.props.setDirty(ev.target.value !== this.lastSetValue);
}
onUpdateInput(date) {
this.props.setDirty(false);
this.lastSetValue = date;
}
}
DateTimeField.template = "web.DateTimeField";
DateTimeField.components = {
DateTimePicker,
};
DateTimeField.props = {
...standardFieldProps,
pickerOptions: { type: Object, optional: true },
placeholder: { type: String, optional: true },
};
DateTimeField.defaultProps = {
pickerOptions: {},
};
DateTimeField.displayName = _lt("Date & Time");
DateTimeField.supportedTypes = ["datetime"];
DateTimeField.extractProps = ({ attrs }) => {
return {
pickerOptions: attrs.options.datepicker,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("datetime", DateTimeField);

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateTimeField" owl="1">
<t t-if="props.readonly">
<span t-esc="formattedValue" />
</t>
<t t-else="">
<DateTimePicker
t-props="props.pickerOptions"
date="props.value"
inputId="props.id"
placeholder="props.placeholder"
onDateTimeChanged="(datetime) => this.onDateTimeChanged(datetime)"
onInput.bind="onDatePickerInput"
onUpdateInput.bind="onUpdateInput"
revId="revId"
/>
</t>
</t>
</templates>

View file

@ -0,0 +1,179 @@
/** @odoo-module **/
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useBus, useService, useOwnedDialogs } from "@web/core/utils/hooks";
import { Domain } from "@web/core/domain";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
export class DomainField extends Component {
setup() {
this.rpc = useService("rpc");
this.orm = useService("orm");
this.state = useState({
recordCount: null,
isValid: true,
});
this.addDialog = useOwnedDialogs();
this.displayedDomain = null;
this.isDebugEdited = false;
onWillStart(() => {
this.displayedDomain = this.props.value;
this.loadCount(this.props);
});
onWillUpdateProps(async (nextProps) => {
this.isDebugEdited = this.isDebugEdited && this.props.readonly === nextProps.readonly;
// Check the manually edited domain and reflect it in the widget if its valid
if (this.isDebugEdited) {
const proms = [];
this.env.bus.trigger("RELATIONAL_MODEL:NEED_LOCAL_CHANGES", { proms });
await Promise.all([...proms]);
}
if (!this.isDebugEdited) {
this.displayedDomain = nextProps.value;
this.loadCount(nextProps);
}
});
useBus(this.env.bus, "RELATIONAL_MODEL:NEED_LOCAL_CHANGES", async (ev) => {
if (this.isDebugEdited) {
const props = this.props;
const prom = this.quickValidityCheck(props);
ev.detail.proms.push(prom);
prom.then((isValid) => {
if (isValid) {
this.isDebugEdited = false; // will allow the count to be loaded if needed
} else {
this.state.isValid = false;
this.state.recordCount = 0;
this.props.record.setInvalidField(props.name);
}
});
}
});
}
async quickValidityCheck(p) {
const model = this.getResModel(p);
if (!model) {
return false;
}
try {
const domain = this.getDomain(p.value).toList(this.getContext(p));
return this.rpc("/web/domain/validate", { model, domain });
} catch (_) {
return false;
}
}
getContext(p) {
return p.record.getFieldContext(p.name);
}
getResModel(p) {
let resModel = p.resModel;
if (p.record.fieldNames.includes(resModel)) {
resModel = p.record.data[resModel];
}
return resModel;
}
onButtonClick() {
this.addDialog(
SelectCreateDialog,
{
title: this.env._t("Selected records"),
noCreate: true,
multiSelect: false,
resModel: this.getResModel(this.props),
domain: this.getDomain(this.props.value).toList(this.getContext(this.props)) || [],
context: this.getContext(this.props) || {},
},
{
// The counter is reloaded "on close" because some modal allows to modify data that can impact the counter
onClose: () => this.loadCount(this.props),
}
);
}
get isValidDomain() {
try {
this.getDomain(this.props.value).toList();
return true;
} catch (_e) {
// WOWL TODO: rethrow error when not the expected type
return false;
}
}
getDomain(value) {
return new Domain(value || "[]");
}
async loadCount(props) {
if (!this.getResModel(props)) {
Object.assign(this.state, { recordCount: 0, isValid: true });
}
let recordCount;
try {
const domain = this.getDomain(props.value).toList(this.getContext(props));
recordCount = await this.orm.silent.call(
this.getResModel(props),
"search_count",
[domain],
{ context: this.getContext(props) }
);
} catch (_e) {
// WOWL TODO: rethrow error when not the expected type
Object.assign(this.state, { recordCount: 0, isValid: false });
return;
}
Object.assign(this.state, { recordCount, isValid: true });
}
update(domain, isDebugEdited) {
this.isDebugEdited = isDebugEdited;
return this.props.update(domain);
}
onEditDialogBtnClick() {
this.addDialog(DomainSelectorDialog, {
resModel: this.getResModel(this.props),
initialValue: this.props.value || "[]",
readonly: this.props.readonly,
isDebugMode: !!this.env.debug,
onSelected: this.props.update,
});
}
}
DomainField.template = "web.DomainField";
DomainField.components = {
DomainSelector,
};
DomainField.props = {
...standardFieldProps,
editInDialog: { type: Boolean, optional: true },
resModel: { type: String, optional: true },
};
DomainField.defaultProps = {
editInDialog: false,
};
DomainField.displayName = _lt("Domain");
DomainField.supportedTypes = ["char"];
DomainField.isEmpty = () => false;
DomainField.extractProps = ({ attrs }) => {
return {
editInDialog: attrs.options.in_dialog,
resModel: attrs.options.model,
};
};
registry.category("fields").add("domain", DomainField);

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainField" owl="1">
<div t-att-class="{ o_inline_mode: !props.editInDialog }">
<t t-if="getResModel(props)">
<DomainSelector
resModel="getResModel(props)"
value="displayedDomain || '[]'"
readonly="props.readonly or props.editInDialog"
update.bind="update"
isDebugMode="!!env.debug"
debugValue="props.value || '[]'"
className="props.readonly ? 'o_read_mode' : 'o_edit_mode'"
/>
<div class="o_field_domain_panel">
<t t-if="state.recordCount !== null">
<i class="fa fa-arrow-right" role="img" aria-label="Domain" title="Domain" />
<t t-if="state.isValid and isValidDomain">
<button class="btn btn-sm btn-secondary o_domain_show_selection_button" type="button" t-on-click.stop="onButtonClick">
<t t-esc="state.recordCount" /> record(s)
</button>
</t>
<t t-else="">
<span class="text-warning" role="alert">
<i class="fa fa-exclamation-triangle" role="img" aria-label="Warning" title="Warning" /> Invalid domain
</span>
</t>
<t t-if="!!env.debug and !props.readonly">
<button
class="btn btn-sm btn-icon fa fa-refresh o_refresh_count"
role="img"
aria-label="Refresh"
title="Refresh"
t-on-click="() => this.loadCount(props)"
/>
</t>
</t>
<t t-else="">
<i class="fa fa-circle-o-notch fa-spin" role="img" aria-label="Loading" title="Loading" />
</t>
<t t-if="props.editInDialog and !props.readonly">
<button class="btn btn-sm btn-primary o_field_domain_dialog_button" t-on-click.prevent="onEditDialogBtnClick">Edit Domain</button>
</t>
</div>
</t>
<t t-else="">
<div>Select a model to add a filter.</div>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,61 @@
/** @odoo-module **/
import { useUniquePopover } from "@web/core/model_field_selector/unique_popover_hook";
import { useModelField } from "@web/core/model_field_selector/model_field_hook";
import { ModelFieldSelectorPopover } from "@web/core/model_field_selector/model_field_selector_popover";
export function useDynamicPlaceholder() {
const popover = useUniquePopover();
const modelField = useModelField();
let dynamicPlaceholderChain = [];
function update(chain) {
dynamicPlaceholderChain = chain;
}
return {
TRIGGER_KEY: '#',
/**
* Open a Model Field Selector which can select fields to create a dynamic
* placeholder string in the Input with or without a default text value.
*
* @public
* @param {HTMLElement} element
* @param {String} baseModel
* @param {Object} options
* @param {function} options.validateCallback
* @param {function} options.closeCallback
* @param {function} [options.positionCallback]
*/
async open(
element,
baseModel,
options = {}
) {
dynamicPlaceholderChain = await modelField.loadChain(baseModel, "");
popover.add(
element,
ModelFieldSelectorPopover,
{
chain: dynamicPlaceholderChain,
update: update,
validate: options.validateCallback,
showSearchInput: true,
isDebugMode: true,
needDefaultValue: true,
loadChain: modelField.loadChain,
filter: (model) => !["one2many", "boolean", "many2many"].includes(model.type),
},
{
closeOnClickAway: true,
onClose: options.closeCallback,
onPositioned: options.positionCallback,
}
);
}
};
}

View file

@ -0,0 +1,34 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { useInputField } from "../input_field_hook";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class EmailField extends Component {
setup() {
useInputField({ getValue: () => this.props.value || "" });
}
}
EmailField.template = "web.EmailField";
EmailField.props = {
...standardFieldProps,
placeholder: { type: String, optional: true },
};
EmailField.extractProps = ({ attrs }) => {
return {
placeholder: attrs.placeholder,
};
};
EmailField.displayName = _lt("Email");
EmailField.supportedTypes = ["char"];
class FormEmailField extends EmailField {}
FormEmailField.template = "web.FormEmailField";
registry.category("fields").add("email", EmailField);
registry.category("fields").add("form.email", FormEmailField);

View file

@ -0,0 +1,7 @@
body:not(.o_touch_device) .o_field_email {
&:not(:hover):not(:focus-within) {
& input:not(:hover) ~ a {
display: none !important;
}
}
}

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.EmailField" owl="1">
<t t-if="props.readonly">
<div class="d-grid">
<a class="o_form_uri o_text_overflow" t-on-click.stop="" t-att-href="props.value ? 'mailto:'+props.value : undefined" t-esc="props.value || ''"/>
</div>
</t>
<t t-else="">
<div class="d-inline-flex w-100">
<input
class="o_input"
t-att-id="props.id"
type="email"
autocomplete="off"
t-att-placeholder="props.placeholder"
t-att-required="props.required"
t-ref="input"
/>
</div>
</t>
</t>
<t t-name="web.FormEmailField" t-inherit="web.EmailField" t-inherit-mode="primary">
<xpath expr="//input" position="after">
<a
t-if="props.value"
t-att-href="'mailto:'+props.value"
class="ms-3 d-inline-flex align-items-center"
>
<i class="fa fa-envelope" data-tooltip="Send Email" aria-label="Send Email"></i>
</a>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,303 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import {
archParseBoolean,
evalDomain,
getClassNameFromDecoration,
X2M_TYPES,
} from "@web/views/utils";
import { getTooltipInfo } from "./field_tooltip";
import { Component, xml } from "@odoo/owl";
const viewRegistry = registry.category("views");
const fieldRegistry = registry.category("fields");
class DefaultField extends Component {}
DefaultField.template = xml``;
function getFieldClassFromRegistry(fieldType, widget, viewType, jsClass) {
if (jsClass && widget) {
const name = `${jsClass}.${widget}`;
if (fieldRegistry.contains(name)) {
return fieldRegistry.get(name);
}
}
if (viewType && widget) {
const name = `${viewType}.${widget}`;
if (fieldRegistry.contains(name)) {
return fieldRegistry.get(name);
}
}
if (widget) {
if (fieldRegistry.contains(widget)) {
return fieldRegistry.get(widget);
}
console.warn(`Missing widget: ${widget} for field of type ${fieldType}`);
}
if (viewType && fieldType) {
const name = `${viewType}.${fieldType}`;
if (fieldRegistry.contains(name)) {
return fieldRegistry.get(name);
}
}
if (fieldRegistry.contains(fieldType)) {
return fieldRegistry.get(fieldType);
}
return DefaultField;
}
export function fieldVisualFeedback(FieldComponent, record, fieldName, fieldInfo) {
const modifiers = fieldInfo.modifiers || {};
const readonly = evalDomain(modifiers.readonly, record.evalContext);
const inEdit = record.isInEdition;
let empty = !record.isVirtual;
if ("isEmpty" in FieldComponent) {
empty = empty && FieldComponent.isEmpty(record, fieldName);
} else {
empty = empty && !record.data[fieldName];
}
empty = inEdit ? empty && readonly : empty;
return {
readonly,
required: evalDomain(modifiers.required, record.evalContext),
invalid: record.isInvalid(fieldName),
empty,
};
}
export class Field extends Component {
setup() {
this.FieldComponent = this.props.fieldInfo.FieldComponent;
if (!this.FieldComponent) {
const fieldType = this.props.record.fields[this.props.name].type;
this.FieldComponent = getFieldClassFromRegistry(fieldType, this.props.type);
}
}
get classNames() {
const { class: _class, fieldInfo, name, record } = this.props;
const { readonly, required, invalid, empty } = fieldVisualFeedback(
this.FieldComponent,
record,
name,
fieldInfo
);
const classNames = {
o_field_widget: true,
o_readonly_modifier: readonly,
o_required_modifier: required,
o_field_invalid: invalid,
o_field_empty: empty,
[`o_field_${this.type}`]: true,
[_class]: Boolean(_class),
};
if (this.FieldComponent.additionalClasses) {
for (const cls of this.FieldComponent.additionalClasses) {
classNames[cls] = true;
}
}
// generate field decorations classNames (only if field-specific decorations
// have been defined in an attribute, e.g. decoration-danger="other_field = 5")
// only handle the text-decoration.
const { decorations } = fieldInfo;
const evalContext = record.evalContext;
for (const decoName in decorations) {
const value = evaluateExpr(decorations[decoName], evalContext);
classNames[getClassNameFromDecoration(decoName)] = value;
}
return classNames;
}
get type() {
return this.props.type || this.props.record.fields[this.props.name].type;
}
get fieldComponentProps() {
const record = this.props.record;
const evalContext = record.evalContext;
const field = record.fields[this.props.name];
const fieldInfo = this.props.fieldInfo;
const readonly = this.props.readonly === true;
const modifiers = fieldInfo.modifiers || {};
const readonlyFromModifiers = evalDomain(modifiers.readonly, evalContext);
// Decoration props
const decorationMap = {};
const { decorations } = fieldInfo;
for (const decoName in decorations) {
const value = evaluateExpr(decorations[decoName], evalContext);
decorationMap[decoName] = value;
}
let propsFromAttrs = fieldInfo.propsFromAttrs;
if (this.props.attrs) {
const extractProps = this.FieldComponent.extractProps || (() => ({}));
propsFromAttrs = extractProps({
field,
attrs: {
...this.props.attrs,
options: evaluateExpr(this.props.attrs.options || "{}"),
},
});
}
const props = { ...this.props };
delete props.style;
delete props.class;
delete props.showTooltip;
delete props.fieldInfo;
delete props.attrs;
delete props.readonly;
return {
...fieldInfo.props,
update: async (value, options = {}) => {
const { save } = Object.assign({ save: false }, options);
await record.update({ [this.props.name]: value });
if (record.selected && record.model.multiEdit) {
return;
}
const rootRecord =
record.model.root instanceof record.constructor && record.model.root;
const isInEdition = rootRecord ? rootRecord.isInEdition : record.isInEdition;
if ((!isInEdition && !readonlyFromModifiers) || save) {
// TODO: maybe move this in the model
return record.save();
}
},
value: this.props.record.data[this.props.name],
decorations: decorationMap,
readonly: readonly || !record.isInEdition || readonlyFromModifiers || false,
...propsFromAttrs,
...props,
type: field.type,
};
}
get tooltip() {
if (this.props.showTooltip) {
const tooltip = getTooltipInfo({
field: this.props.record.fields[this.props.name],
fieldInfo: this.props.fieldInfo,
});
if (Boolean(odoo.debug) || (tooltip && JSON.parse(tooltip).field.help)) {
return tooltip;
}
}
return false;
}
}
Field.template = "web.Field";
Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) {
const name = node.getAttribute("name");
const widget = node.getAttribute("widget");
const fields = models[modelName];
const field = fields[name];
const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}");
const fieldInfo = {
name,
viewType,
context: node.getAttribute("context") || "{}",
string: node.getAttribute("string") || field.string,
help: node.getAttribute("help"),
widget,
modifiers,
onChange: archParseBoolean(node.getAttribute("on_change")),
FieldComponent: getFieldClassFromRegistry(fields[name].type, widget, viewType, jsClass),
forceSave: archParseBoolean(node.getAttribute("force_save")),
decorations: {}, // populated below
noLabel: archParseBoolean(node.getAttribute("nolabel")),
props: {},
rawAttrs: {},
options: evaluateExpr(node.getAttribute("options") || "{}"),
alwaysInvisible: modifiers.invisible === true || modifiers.column_invisible === true,
};
if (node.getAttribute("domain")) {
fieldInfo.domain = node.getAttribute("domain");
}
for (const attribute of node.attributes) {
if (attribute.name in Field.forbiddenAttributeNames) {
throw new Error(Field.forbiddenAttributeNames[attribute.name]);
}
// prepare field decorations
if (attribute.name.startsWith("decoration-")) {
const decorationName = attribute.name.replace("decoration-", "");
fieldInfo.decorations[decorationName] = attribute.value;
continue;
}
if (!attribute.name.startsWith("t-att")) {
fieldInfo.rawAttrs[attribute.name] = attribute.value;
}
}
if (viewType !== "kanban") {
// FIXME WOWL: find a better solution
const extractProps = fieldInfo.FieldComponent.extractProps || (() => ({}));
fieldInfo.propsFromAttrs = extractProps({
field,
attrs: { ...fieldInfo.rawAttrs, options: fieldInfo.options },
});
}
if (X2M_TYPES.includes(field.type)) {
const views = {};
for (const child of node.children) {
const viewType = child.tagName === "tree" ? "list" : child.tagName;
const { ArchParser } = viewRegistry.get(viewType);
const xmlSerializer = new XMLSerializer();
const subArch = xmlSerializer.serializeToString(child);
const archInfo = new ArchParser().parse(subArch, models, field.relation);
views[viewType] = {
...archInfo,
fields: models[field.relation],
};
fieldInfo.relatedFields = models[field.relation];
}
let viewMode = node.getAttribute("mode");
if (!viewMode) {
if (views.list && !views.kanban) {
viewMode = "list";
} else if (!views.list && views.kanban) {
viewMode = "kanban";
} else if (views.list && views.kanban) {
viewMode = "list,kanban";
}
} else {
viewMode = viewMode.replace("tree", "list");
}
fieldInfo.viewMode = viewMode;
const fieldsToFetch = { ...fieldInfo.FieldComponent.fieldsToFetch }; // should become an array?
// special case for color field
// GES: this is not nice, we will look for something better.
const colorField = fieldInfo.options.color_field;
if (colorField) {
fieldsToFetch[colorField] = { name: colorField, type: "integer", active: true };
}
fieldInfo.fieldsToFetch = fieldsToFetch;
fieldInfo.relation = field.relation; // not really necessary
fieldInfo.views = views;
}
return fieldInfo;
};
Field.forbiddenAttributeNames = {
decorations: `You cannot use the "decorations" attribute name as it is used as generated prop name for the composite decoration-<something> attributes.`,
};
Field.defaultProps = { fieldInfo: {}, setDirty: () => {} };

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.Field" owl="1">
<div t-att-name="props.name" t-att-class="classNames" t-att-style="props.style" t-att-data-tooltip-template="tooltip and 'web.FieldTooltip'" t-att-data-tooltip-info="tooltip">
<t t-component="FieldComponent" t-props="fieldComponentProps"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,30 @@
/** @odoo-module **/
export function getTooltipInfo(params) {
let widgetDescription = undefined;
if (params.fieldInfo.widget) {
widgetDescription = params.fieldInfo.FieldComponent.displayName;
}
const info = {
viewMode: params.viewMode,
resModel: params.resModel,
debug: Boolean(odoo.debug),
field: {
label: params.field.string,
name: params.field.name,
help: params.fieldInfo.help !== null ? params.fieldInfo.help : params.field.help,
type: params.field.type,
widget: params.fieldInfo.widget,
widgetDescription,
context: params.fieldInfo.context,
domain: params.fieldInfo.domain || params.field.domain,
modifiers: JSON.stringify(params.fieldInfo.modifiers),
changeDefault: params.field.change_default,
relation: params.field.relation,
selection: params.field.selection,
default: params.field.default,
},
};
return JSON.stringify(info);
}

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FieldTooltip" owl="1">
<p t-if="field.help" class="o-tooltip--help" role="tooltip">
<t t-esc="field.help"/>
</p>
<ul class="o-tooltip--technical" t-if="debug" role="tooltip">
<li data-item="field" t-if="field and field.name">
<span class="o-tooltip--technical--title">Field:</span>
<t t-esc="field.name"/>
</li>
<li data-item="object" t-if="resModel">
<span class="o-tooltip--technical--title">Model:</span>
<t t-esc="resModel"/>
</li>
<t t-if="field">
<li t-if="field.type" data-item="type" >
<span class="o-tooltip--technical--title">Type:</span>
<t t-esc="field.type"/>
</li>
<li t-if="field.widget" data-item="widget">
<span class="o-tooltip--technical--title">Widget:</span>
<t t-if="field.widgetDescription" t-esc="field.widgetDescription"/>
<t t-if="field.widget">
(<t t-esc="field.widget"/>)
</t>
</li>
<li t-if="field.context" data-item="context">
<span class="o-tooltip--technical--title">Context:</span>
<t t-esc="field.context"/>
</li>
<li t-if="field.domain" data-item="domain">
<span class="o-tooltip--technical--title">Domain:</span>
<t t-esc="field.domain.length === 0 ? '[]' : field.domain"/>
</li>
<li t-if="field.modifiers" data-item="modifiers">
<span class="o-tooltip--technical--title">Modifiers:</span>
<t t-esc="field.modifiers"/>
</li>
<li t-if="field.default" data-item="default">
<span class="o-tooltip--technical--title">Default:</span>
<t t-esc="field.default"/>
</li>
<li t-if="field.changeDefault" data-item="changeDefault">
<span class="o-tooltip--technical--title">Change default:</span>
Yes
</li>
<li t-if="field.relation" data-item="relation">
<span class="o-tooltip--technical--title">Relation:</span>
<t t-esc="field.relation"/>
</li>
<li t-if="field.selection" data-item="selection">
<span class="o-tooltip--technical--title">Selection:</span>
<ul class="o-tooltip--technical">
<li t-foreach="field.selection" t-as="option" t-key="option_index">
[<t t-esc="option[0]"/>]
<t t-if="option[1]"> - </t>
<t t-esc="option[1]"/>
</li>
</ul>
</li>
</t>
</ul>
</t>
</templates>

View file

@ -0,0 +1,20 @@
.o_field_cursor_disabled {
cursor: not-allowed;
}
// .o_field_highlight is used in several types of view to force fields
// to be displayed with a bottom border even when not hovered (e.g. added
// by mobile detection, in several specific places such as spreadsheet or
// knowledge sidebars, settings view, kanban quick create (not a form view), etc.)
.o_field_highlight .o_field_widget .o_input, .o_field_highlight.o_field_widget .o_input {
border-color: var(--o-input-border-color);
}
// field decoration classes (e.g. text-danger) are set on the field root node,
// but bootstrap contextual classes do not work on input/textarea, so we have to
// explicitely set their color to the one of their parent
.o_field_widget {
input, textarea, select {
color: inherit;
}
}

View file

@ -0,0 +1,64 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { checkFileSize } from "@web/core/utils/files";
import { getDataURLFromFile } from "@web/core/utils/urls";
import { Component, useRef, useState } from "@odoo/owl";
export class FileUploader extends Component {
setup() {
this.notification = useService("notification");
this.fileInputRef = useRef("fileInput");
this.state = useState({
isUploading: false,
});
}
/**
* @param {Event} ev
*/
async onFileChange(ev) {
if (!ev.target.files.length) {
return;
}
const { target } = ev;
for (const file of ev.target.files) {
if (!checkFileSize(file.size, this.notification)) {
return null;
}
this.state.isUploading = true;
const data = await getDataURLFromFile(file);
if (!file.size) {
console.warn(`Error while uploading file : ${file.name}`);
this.notification.add(
this.env._t("There was a problem while uploading your file."),
{
type: "danger",
}
);
}
try {
await this.props.onUploaded({
name: file.name,
size: file.size,
type: file.type,
data: data.split(",")[1],
objectUrl: file.type === "application/pdf" ? URL.createObjectURL(file) : null,
});
} finally {
this.state.isUploading = false;
}
}
target.value = null;
if (this.props.multiUpload && this.props.onUploadComplete) {
this.props.onUploadComplete({});
}
}
onSelectFileButtonClick() {
this.fileInputRef.el.click();
}
}
FileUploader.template = "web.FileUploader";

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FileUploader" owl="1">
<t t-if="state.isUploading">Uploading...</t>
<span t-else="" t-on-click.prevent="onSelectFileButtonClick" style="display:contents">
<t t-slot="toggler"/>
</span>
<t t-slot="default"/>
<input
type="file"
t-att-name="props.inputName"
t-ref="fileInput"
t-attf-class="o_input_file o_hidden {{ props.fileUploadClass or '' }}"
t-att-multiple="props.multiUpload ? 'multiple' : false" t-att-accept="props.acceptedFileExtensions or '*'"
t-on-change="onFileChange"
/>
</t>
</templates>

View file

@ -0,0 +1,70 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useInputField } from "../input_field_hook";
import { useNumpadDecimal } from "../numpad_decimal_hook";
import { formatFloat } from "../formatters";
import { parseFloat } from "../parsers";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class FloatField extends Component {
setup() {
this.inputRef = useInputField({
getValue: () => this.formattedValue,
refName: "numpadDecimal",
parse: (v) => this.parse(v),
});
useNumpadDecimal();
}
parse(value) {
return this.props.inputType === "number" ? Number(value) : parseFloat(value);
}
get formattedValue() {
if (this.props.inputType === "number" && !this.props.readonly && this.props.value) {
return this.props.value;
}
return formatFloat(this.props.value, { digits: this.props.digits });
}
}
FloatField.template = "web.FloatField";
FloatField.props = {
...standardFieldProps,
inputType: { type: String, optional: true },
step: { type: Number, optional: true },
digits: { type: Array, optional: true },
placeholder: { type: String, optional: true },
};
FloatField.defaultProps = {
inputType: "text",
};
FloatField.displayName = _lt("Float");
FloatField.supportedTypes = ["float"];
FloatField.isEmpty = () => false;
FloatField.extractProps = ({ attrs, field }) => {
// Sadly, digits param was available as an option and an attr.
// The option version could be removed with some xml refactoring.
let digits;
if (attrs.digits) {
digits = JSON.parse(attrs.digits);
} else if (attrs.options.digits) {
digits = attrs.options.digits;
} else if (Array.isArray(field.digits)) {
digits = field.digits;
}
return {
inputType: attrs.options.type,
step: attrs.options.step,
digits,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("float", FloatField);

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FloatField" owl="1">
<span t-if="props.readonly" t-esc="formattedValue" />
<input t-else="" t-att-id="props.id" t-ref="numpadDecimal" autocomplete="off" t-att-placeholder="props.placeholder" t-att-type="props.inputType" inputmode="decimal" class="o_input" t-att-step="props.step" />
</t>
</templates>

View file

@ -0,0 +1,44 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { FloatField } from "../float/float_field";
import { Component } from "@odoo/owl";
export class FloatFactorField extends Component {
get factor() {
return this.props.factor;
}
get floatFieldProps() {
const result = {
...this.props,
value: this.props.value * this.factor,
update: (value) => this.props.update(value / this.factor),
};
delete result.factor;
return result;
}
}
FloatFactorField.template = "web.FloatFactorField";
FloatFactorField.components = { FloatField };
FloatFactorField.props = {
...FloatField.props,
factor: { type: Number, optional: true },
};
FloatFactorField.defaultProps = {
...FloatField.defaultProps,
factor: 1,
};
FloatFactorField.supportedTypes = ["float"];
FloatFactorField.isEmpty = () => false;
FloatFactorField.extractProps = ({ attrs, field }) => {
return {
...FloatField.extractProps({ attrs, field }),
factor: attrs.options.factor,
};
};
registry.category("fields").add("float_factor", FloatFactorField);

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FloatFactorField" owl="1">
<FloatField t-props="floatFieldProps" />
</t>
</templates>

View file

@ -0,0 +1,49 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { formatFloatTime } from "../formatters";
import { parseFloatTime } from "../parsers";
import { useInputField } from "../input_field_hook";
import { standardFieldProps } from "../standard_field_props";
import { useNumpadDecimal } from "../numpad_decimal_hook";
import { Component } from "@odoo/owl";
export class FloatTimeField extends Component {
setup() {
useInputField({
getValue: () => this.formattedValue,
refName: "numpadDecimal",
parse: (v) => parseFloatTime(v),
});
useNumpadDecimal();
}
get formattedValue() {
return formatFloatTime(this.props.value);
}
}
FloatTimeField.template = "web.FloatTimeField";
FloatTimeField.props = {
...standardFieldProps,
inputType: { type: String, optional: true },
placeholder: { type: String, optional: true },
};
FloatTimeField.defaultProps = {
inputType: "text",
};
FloatTimeField.displayName = _lt("Time");
FloatTimeField.supportedTypes = ["float"];
FloatTimeField.isEmpty = () => false;
FloatTimeField.extractProps = ({ attrs }) => {
return {
inputType: attrs.options.type,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("float_time", FloatTimeField);

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FloatTimeField" owl="1">
<span t-if="props.readonly" t-esc="formattedValue" />
<input t-else="" t-att-id="props.id" t-att-type="props.inputType" t-ref="numpadDecimal" t-att-placeholder="props.placeholder" class="o_input" autocomplete="off" />
</t>
</templates>

View file

@ -0,0 +1,67 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { formatFloat } from "../formatters";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class FloatToggleField extends Component {
// TODO perf issue (because of update round trip)
// we probably want to have a state and a useEffect or onWillUpateProps
onChange() {
let currentIndex = this.props.range.indexOf(this.props.value * this.factor);
currentIndex++;
if (currentIndex > this.props.range.length - 1) {
currentIndex = 0;
}
this.props.update(this.props.range[currentIndex] / this.factor);
}
// This property has been created in order to allow overrides in other modules.
get factor() {
return this.props.factor;
}
get formattedValue() {
return formatFloat(this.props.value * this.factor, {
digits: this.props.digits,
});
}
}
FloatToggleField.template = "web.FloatToggleField";
FloatToggleField.props = {
...standardFieldProps,
digits: { type: Array, optional: true },
range: { type: Array, optional: true },
factor: { type: Number, optional: true },
disableReadOnly: { type: Boolean, optional: true },
};
FloatToggleField.defaultProps = {
range: [0.0, 0.5, 1.0],
factor: 1,
disableReadOnly: false,
};
FloatToggleField.supportedTypes = ["float"];
FloatToggleField.isEmpty = () => false;
FloatToggleField.extractProps = ({ attrs, field }) => {
let digits;
if (attrs.digits) {
digits = JSON.parse(attrs.digits);
} else if (attrs.options.digits) {
digits = attrs.options.digits;
} else if (Array.isArray(field.digits)) {
digits = field.digits;
}
return {
digits,
range: attrs.options.range,
factor: attrs.options.factor,
disableReadOnly: attrs.options.force_button || false,
};
};
registry.category("fields").add("float_toggle", FloatToggleField);

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FloatToggleField" owl="1">
<span t-if="props.readonly and !props.disableReadOnly" t-esc="formattedValue" />
<button t-else="" class="o_field_float_toggle" t-on-click="onChange"><t t-esc="formattedValue" /></button>
</t>
</templates>

View file

@ -0,0 +1,52 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { formatSelection } from "../formatters";
import { Component } from "@odoo/owl";
export class FontSelectionField extends Component {
get options() {
return this.props.record.fields[this.props.name].selection.filter(
(option) => option[0] !== false && option[1] !== ""
);
}
get isRequired() {
return this.props.record.isRequired(this.props.name);
}
get string() {
return formatSelection(this.props.value, { selection: this.options });
}
stringify(value) {
return JSON.stringify(value);
}
/**
* @param {Event} ev
*/
onChange(ev) {
const value = JSON.parse(ev.target.value);
this.props.update(value);
}
}
FontSelectionField.template = "web.FontSelectionField";
FontSelectionField.props = {
...standardFieldProps,
placeholder: { type: String, optional: true },
};
FontSelectionField.displayName = _lt("Font Selection");
FontSelectionField.supportedTypes = ["selection"];
FontSelectionField.legacySpecialData = "_fetchSpecialRelation";
FontSelectionField.extractProps = ({ attrs }) => {
return {
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("font", FontSelectionField);

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FontSelectionField" owl="1">
<t t-if="props.readonly">
<span t-esc="string" t-att-raw-value="props.value" t-attf-style="font-family:{{ props.value }};"/>
</t>
<t t-else="">
<select class="o_input" t-on-change="onChange" t-attf-style="font-family:{{ props.value }};">
<option
t-att-selected="false === value"
t-att-value="stringify(false)"
t-esc="this.props.placeholder || ''"
t-attf-style="{{ isRequired ? 'display:none' : '' }}"
/>
<t t-foreach="options" t-as="option" t-key="option[0]">
<option
t-att-selected="option[0] === value"
t-att-value="stringify(option[0])"
t-esc="option[1]"
t-attf-style="font-family:{{ option[1] }};"
/>
</t>
</select>
</t>
</t>
</templates>

View file

@ -0,0 +1,409 @@
/** @odoo-module **/
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
import { localization as l10n } from "@web/core/l10n/localization";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { escape, nbsp, sprintf } from "@web/core/utils/strings";
import { isBinarySize } from "@web/core/utils/binary";
import { session } from "@web/session";
import { humanNumber, insertThousandsSep } from "@web/core/utils/numbers";
import { markup } from "@odoo/owl";
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
function humanSize(value) {
if (!value) {
return "";
}
const suffix = value < 1024 ? " " + _t("Bytes") : "b";
return (
humanNumber(value, {
decimals: 2,
}) + suffix
);
}
// -----------------------------------------------------------------------------
// Exports
// -----------------------------------------------------------------------------
/**
* @param {string} [value] base64 representation of the binary
* @returns {string}
*/
export function formatBinary(value) {
if (!isBinarySize(value)) {
// Computing approximate size out of base64 encoded string
// http://en.wikipedia.org/wiki/Base64#MIME
return humanSize(value.length / 1.37);
}
// already bin_size
return value;
}
/**
* @param {boolean} value
* @returns {string}
*/
export function formatBoolean(value) {
return markup(`
<div class="o-checkbox d-inline-block me-2">
<input id="boolean_checkbox" type="checkbox" class="form-check-input" disabled ${
value ? "checked" : ""
}/>
<label for="boolean_checkbox" class="form-check-label"/>
</div>`);
}
/**
* Returns a string representing a char. If the value is false, then we return
* an empty string.
*
* @param {string|false} value
* @param {Object} [options] additional options
* @param {boolean} [options.escape=false] if true, escapes the formatted value
* @param {boolean} [options.isPassword=false] if true, returns '********'
* instead of the formatted value
* @returns {string}
*/
export function formatChar(value, options) {
value = typeof value === "string" ? value : "";
if (options && options.isPassword) {
return "*".repeat(value ? value.length : 0);
}
if (options && options.escape) {
value = escape(value);
}
return value;
}
/**
* Returns a string representing a float. The result takes into account the
* user settings (to display the correct decimal separator).
*
* @param {number | false} value the value that should be formatted
* @param {Object} [options]
* @param {number[]} [options.digits] the number of digits that should be used,
* instead of the default digits precision in the field.
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
* to a human readable format.
* @param {string} [options.decimalPoint] decimal separating character
* @param {string} [options.thousandsSep] thousands separator to insert
* @param {number[]} [options.grouping] array of relative offsets at which to
* insert `thousandsSep`. See `insertThousandsSep` method.
* @param {boolean} [options.noTrailingZeros=false] if true, the decimal part
* won't contain unnecessary trailing zeros.
* @returns {string}
*/
export function formatFloat(value, options = {}) {
if (value === false) {
return "";
}
if (options.humanReadable) {
return humanNumber(value, options);
}
const grouping = options.grouping || l10n.grouping;
const thousandsSep = "thousandsSep" in options ? options.thousandsSep : l10n.thousandsSep;
const decimalPoint = "decimalPoint" in options ? options.decimalPoint : l10n.decimalPoint;
let precision;
if (options.digits && options.digits[1] !== undefined) {
precision = options.digits[1];
} else {
precision = 2;
}
const formatted = (value || 0).toFixed(precision).split(".");
formatted[0] = insertThousandsSep(formatted[0], thousandsSep, grouping);
if (options.noTrailingZeros) {
formatted[1] = formatted[1].replace(/0+$/, "");
}
return formatted[1] ? formatted.join(decimalPoint) : formatted[0];
}
/**
* Returns a string representing a float value, from a float converted with a
* factor.
*
* @param {number | false} value
* @param {Object} [options]
* @param {number} [options.factor=1.0] conversion factor
* @returns {string}
*/
export function formatFloatFactor(value, options = {}) {
if (value === false) {
return "";
}
const factor = options.factor || 1;
return formatFloat(value * factor, options);
}
/**
* Returns a string representing a time value, from a float. The idea is that
* we sometimes want to display something like 1:45 instead of 1.75, or 0:15
* instead of 0.25.
*
* @param {number | false} value
* @param {Object} [options]
* @param {boolean} [options.noLeadingZeroHour] if true, format like 1:30 otherwise, format like 01:30
* @param {boolean} [options.displaySeconds] if true, format like ?1:30:00 otherwise, format like ?1:30
* @returns {string}
*/
export function formatFloatTime(value, options = {}) {
if (value === false) {
return "";
}
const isNegative = value < 0;
value = Math.abs(value);
let hour = Math.floor(value);
const milliSecLeft = Math.round(value * 3600000) - hour * 3600000;
// Although looking quite overkill, the following lines ensures that we do
// not have float issues while still considering that 59s is 00:00.
let min = milliSecLeft / 60000;
if (options.displaySeconds) {
min = Math.floor(min);
} else {
min = Math.round(min);
}
if (min === 60) {
min = 0;
hour = hour + 1;
}
min = String(min).padStart(2, "0");
if (!options.noLeadingZeroHour) {
hour = String(hour).padStart(2, "0");
}
let sec = "";
if (options.displaySeconds) {
sec = ":" + String(Math.floor((milliSecLeft % 60000) / 1000)).padStart(2, "0");
}
return `${isNegative ? "-" : ""}${hour}:${min}${sec}`;
}
/**
* Returns a string representing an integer. If the value is false, then we
* return an empty string.
*
* @param {number | false | null} value
* @param {Object} [options]
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
* to a human readable format.
* @param {boolean} [options.isPassword=false] if returns true, acts like
* @param {string} [options.thousandsSep] thousands separator to insert
* @param {number[]} [options.grouping] array of relative offsets at which to
* insert `thousandsSep`. See `insertThousandsSep` method.
* @returns {string}
*/
export function formatInteger(value, options = {}) {
if (value === false || value === null) {
return "";
}
if (options.isPassword) {
return "*".repeat(value.length);
}
if (options.humanReadable) {
return humanNumber(value, options);
}
const grouping = options.grouping || l10n.grouping;
const thousandsSep = "thousandsSep" in options ? options.thousandsSep : l10n.thousandsSep;
return insertThousandsSep(value.toFixed(0), thousandsSep, grouping);
}
/**
* Returns a string representing a many2one value. The value is expected to be
* either `false` or an array in the form [id, display_name]. The returned
* value will then be the display name of the given value, or an empty string
* if the value is false.
*
* @param {[number, string] | false} value
* @param {Object} [options] additional options
* @param {boolean} [options.escape=false] if true, escapes the formatted value
* @returns {string}
*/
export function formatMany2one(value, options) {
if (!value) {
value = "";
} else {
value = value[1] || "";
}
if (options && options.escape) {
value = encodeURIComponent(value);
}
return value;
}
/**
* Returns a string representing a one2many or many2many value. The value is
* expected to be either `false` or an array of ids. The returned value will
* then be the count of ids in the given value in the form "x record(s)".
*
* @param {number[] | false} value
* @returns {string}
*/
export function formatX2many(value) {
const count = value.currentIds.length;
if (count === 0) {
return _t("No records");
} else if (count === 1) {
return _t("1 record");
} else {
return sprintf(_t("%s records"), count);
}
}
/**
* Returns a string representing a monetary value. The result takes into account
* the user settings (to display the correct decimal separator, currency, ...).
*
* @param {number | false} value the value that should be formatted
* @param {Object} [options]
* additional options to override the values in the python description of the
* field.
* @param {number} [options.currencyId] the id of the 'res.currency' to use
* @param {string} [options.currencyField] the name of the field whose value is
* the currency id (ignored if options.currency_id).
* Note: if not given it will default to the field "currency_field" value or
* on "currency_id".
* @param {Object} [options.data] a mapping of field names to field values,
* required with options.currencyField
* @param {boolean} [options.noSymbol] this currency has not a sympbol
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
* to a human readable format.
* @param {[number, number]} [options.digits] the number of digits that should
* be used, instead of the default digits precision in the field. The first
* number is always ignored (legacy constraint)
* @returns {string}
*/
export function formatMonetary(value, options = {}) {
// Monetary fields want to display nothing when the value is unset.
// You wouldn't want a value of 0 euro if nothing has been provided.
if (value === false) {
return "";
}
let currencyId = options.currencyId;
if (!currencyId && options.data) {
const currencyField =
options.currencyField ||
(options.field && options.field.currency_field) ||
"currency_id";
const dataValue = options.data[currencyField];
currencyId = Array.isArray(dataValue) ? dataValue[0] : dataValue;
}
const currency = session.currencies[currencyId];
const digits = options.digits || (currency && currency.digits);
let formattedValue;
if (options.humanReadable) {
formattedValue = humanNumber(value, { decimals: digits ? digits[1] : 2 });
} else {
formattedValue = formatFloat(value, { digits });
}
if (!currency || options.noSymbol) {
return formattedValue;
}
const formatted = [currency.symbol, formattedValue];
if (currency.position === "after") {
formatted.reverse();
}
return formatted.join(nbsp);
}
/**
* Returns a string representing the given value (multiplied by 100)
* concatenated with '%'.
*
* @param {number | false} value
* @param {Object} [options]
* @param {boolean} [options.noSymbol] if true, doesn't concatenate with "%"
* @returns {string}
*/
export function formatPercentage(value, options = {}) {
value = value || 0;
options = Object.assign({ noTrailingZeros: true, thousandsSep: "" }, options);
const formatted = formatFloat(value * 100, options);
return `${formatted}${options.noSymbol ? "" : "%"}`;
}
/**
* Returns a string representing the value of the python properties field
* or a properties definition field (see fields.py@Properties).
*
* @param {array|false} value
* @param {Object} [field]
* a description of the field (note: this parameter is ignored)
*/
function formatProperties(value, field) {
if (!value || !value.length) {
return "";
}
return value.map((property) => property["string"]).join(", ");
}
/**
* Returns a string representing the value of the reference field.
*
* @param {Object|false} value Object with keys "resId" and "displayName"
* @param {Object} [options={}]
* @returns {string}
*/
export function formatReference(value, options) {
return formatMany2one(value ? [value.resId, value.displayName] : false, options);
}
/**
* Returns a string of the value of the selection.
*
* @param {Object} [options={}]
* @param {[string, string][]} [options.selection]
* @param {Object} [options.field]
* @returns {string}
*/
export function formatSelection(value, options = {}) {
const selection = options.selection || (options.field && options.field.selection) || [];
const option = selection.find((option) => option[0] === value);
return option ? option[1] : "";
}
/**
* Returns the value or an empty string if it's falsy.
*
* @param {string | false} value
* @returns {string}
*/
export function formatText(value) {
return value || "";
}
export function formatJson(value) {
return (value && JSON.stringify(value)) || "";
}
registry
.category("formatters")
.add("binary", formatBinary)
.add("boolean", formatBoolean)
.add("char", formatChar)
.add("date", formatDate)
.add("datetime", formatDateTime)
.add("float", formatFloat)
.add("float_factor", formatFloatFactor)
.add("float_time", formatFloatTime)
.add("html", (value) => value)
.add("integer", formatInteger)
.add("json", formatJson)
.add("many2one", formatMany2one)
.add("many2one_reference", formatInteger)
.add("one2many", formatX2many)
.add("many2many", formatX2many)
.add("monetary", formatMonetary)
.add("percentage", formatPercentage)
.add("properties", formatProperties)
.add("properties_definition", formatProperties)
.add("reference", formatReference)
.add("selection", formatSelection)
.add("text", formatText);

View file

@ -0,0 +1,19 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class HandleField extends Component {}
HandleField.template = "web.HandleField";
HandleField.props = {
...standardFieldProps,
};
HandleField.displayName = _lt("Handle");
HandleField.supportedTypes = ["integer"];
HandleField.isEmpty = () => false;
registry.category("fields").add("handle", HandleField);

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.HandleField" owl="1">
<span class="o_row_handle fa fa-sort ui-sortable-handle" t-on-click.stop="" />
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show more