mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 19:12:06 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CalendarCommonRenderer" owl="1">
|
||||
<div class="o_calendar_widget" t-ref="fullCalendar" />
|
||||
</t>
|
||||
|
||||
<t t-name="web.CalendarCommonRenderer.event" owl="1">
|
||||
<div class="fc-content">
|
||||
<t t-if="!isTimeHidden">
|
||||
<span class="fc-time" t-esc="startTime" />
|
||||
<t> </t>
|
||||
</t>
|
||||
<div class="o_event_title" t-esc="title"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CalendarController" owl="1">
|
||||
<div t-att-class="className" t-ref="root">
|
||||
<Layout className="model.useSampleModel ? 'o_view_sample_data' : ''" display="props.display">
|
||||
<t t-set-slot="layout-buttons">
|
||||
<t t-call="{{ props.buttonTemplate }}"/>
|
||||
</t>
|
||||
|
||||
<div class="o_calendar_container">
|
||||
<MobileFilterPanel t-if="env.isSmall" t-props="mobileFilterPanelProps" />
|
||||
<t t-if="showCalendar" t-component="props.Renderer" t-props="rendererProps" />
|
||||
<div t-if="showSideBar" class="o_calendar_sidebar_container d-md-block">
|
||||
<div class="o_calendar_sidebar">
|
||||
<DatePicker t-if="!env.isSmall" t-props="datePickerProps" />
|
||||
<FilterPanel t-props="filterPanelProps" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web.CalendarController.controlButtons" owl="1">
|
||||
<div class="o_calendar_buttons">
|
||||
<span t-if="!env.isSmall" class="o_calendar_navigation_buttons me-1">
|
||||
<button
|
||||
class="btn btn-primary o_calendar_button_prev"
|
||||
title="Previous"
|
||||
aria-label="Previous"
|
||||
t-on-click.stop="() => this.setDate('previous')"
|
||||
>
|
||||
<i class="fa fa-arrow-left" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary mx-1 o_calendar_button_today"
|
||||
t-on-click.stop="() => this.setDate('today')"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary o_calendar_button_next"
|
||||
title="Next"
|
||||
aria-label="Next"
|
||||
t-on-click.stop="() => this.setDate('next')"
|
||||
>
|
||||
<i class="fa fa-arrow-right" />
|
||||
</button>
|
||||
</span>
|
||||
<span class="o_calendar_scale_buttons">
|
||||
<Dropdown class="'btn-group'" togglerClass="'btn btn-secondary scale_button_selection text-uppercase'" hotkey="'v'" showCaret="true">
|
||||
<t t-set-slot="toggler">
|
||||
<t t-esc="scaleLabels[model.scale]" />
|
||||
</t>
|
||||
<t t-foreach="model.scales" t-as="scale" t-key="scale">
|
||||
<DropdownItem
|
||||
class="`o_calendar_button_${scale} btn btn-secondary text-uppercase`"
|
||||
hotkey="scale[0]"
|
||||
onSelected="() => this.setScale(scale)"
|
||||
>
|
||||
<t t-esc="scaleLabels[scale]" />
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</span>
|
||||
<button t-if="env.isSmall" class="o_cp_today_button btn btn-sm btn-link" t-on-click="() => this.setDate('today')">
|
||||
<span class="fa-stack o_calendar_button_today">
|
||||
<i class="fa fa-calendar-o fa-stack-2x" role="img" aria-label="Today" title="Today" />
|
||||
<strong class="o_calendar_text fa-stack-1x" t-esc="getTodayDay()" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
@include media-breakpoint-down(md) {
|
||||
.o_control_panel {
|
||||
.o_calendar_button_today {
|
||||
font-size: 0.7em;
|
||||
line-height: 1.9em;
|
||||
> .o_calendar_text {
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_calendar_container {
|
||||
flex-direction: column;
|
||||
|
||||
.o_other_calendar_panel {
|
||||
padding: 8px 16px;
|
||||
|
||||
.fa-filter {
|
||||
min-height: 1.59rem;
|
||||
line-height: 1.59rem;
|
||||
}
|
||||
|
||||
> .o_filter {
|
||||
> span > span {
|
||||
font-size: x-small;
|
||||
&:nth-child(1) {
|
||||
font-size: xx-small;
|
||||
}
|
||||
}
|
||||
@for $i from 1 through length($o-colors-complete) {
|
||||
$color: nth($o-colors-complete, $i);
|
||||
|
||||
.o_color_#{$i - 1} {
|
||||
color: $color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_calendar_sidebar_container {
|
||||
flex-grow: 1;
|
||||
|
||||
> .o_calendar_sidebar {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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"];
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_calendar_renderer .o_calendar_widget .o_calendar_disabled {
|
||||
background-color: $gray-300;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CalendarRenderer" owl="1">
|
||||
<div class="o_calendar_renderer">
|
||||
<ActionSwiper t-props="actionSwiperProps">
|
||||
<t
|
||||
t-component="calendarComponent"
|
||||
t-props="props"
|
||||
t-key="calendarKey"
|
||||
/>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
@include media-breakpoint-down(md) {
|
||||
|
||||
.o_calendar_renderer {
|
||||
.fc-view {
|
||||
&.fc-timeGrid-view {
|
||||
.fc-axis {
|
||||
padding-left: 0;
|
||||
}
|
||||
.fc-week-number.fc-widget-header {
|
||||
font-weight: normal;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
&:not(.fc-timeGridDay-view) {
|
||||
.fc-day-header, .fc-week-number.fc-widget-header {
|
||||
word-spacing: 250px; // force line break in week mode
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
&.fc-dayGridMonth-view {
|
||||
padding-left: 0;
|
||||
.fc-week-number:not(.fc-widget-header) {
|
||||
background-color: $gray-400;
|
||||
color: $gray-800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fc-more-popover .fc-close {
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { CalendarRenderer } from "./calendar_renderer";
|
||||
import { CalendarArchParser } from "./calendar_arch_parser";
|
||||
import { CalendarModel } from "./calendar_model";
|
||||
import { CalendarController } from "./calendar_controller";
|
||||
|
||||
export const calendarView = {
|
||||
type: "calendar",
|
||||
|
||||
display_name: "Calendar",
|
||||
icon: "fa fa-calendar",
|
||||
multiRecord: true,
|
||||
searchMenuTypes: ["filter", "favorite"],
|
||||
|
||||
ArchParser: CalendarArchParser,
|
||||
Controller: CalendarController,
|
||||
Model: CalendarModel,
|
||||
Renderer: CalendarRenderer,
|
||||
|
||||
buttonTemplate: "web.CalendarController.controlButtons",
|
||||
|
||||
props: (props, view) => {
|
||||
const { ArchParser } = view;
|
||||
const { arch, relatedModels, resModel } = props;
|
||||
const archInfo = new ArchParser().parse(arch, relatedModels, resModel);
|
||||
return {
|
||||
...props,
|
||||
Model: view.Model,
|
||||
Renderer: view.Renderer,
|
||||
buttonTemplate: view.buttonTemplate,
|
||||
archInfo,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("views").add("calendar", calendarView);
|
||||
|
|
@ -0,0 +1,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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CalendarYearRenderer" owl="1">
|
||||
<div class="o_calendar_widget" t-ref="root">
|
||||
<div class="fc-view-container">
|
||||
<div class="fc fc-dayGridYear-view">
|
||||
<t t-foreach="months" t-as="month" t-key="month">
|
||||
<div class="fc-month-container">
|
||||
<div class="fc-month" t-ref="fullCalendar-{{ month }}" />
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
const CSS_COLOR_REGEX = /^((#[A-F0-9]{3})|(#[A-F0-9]{6})|((hsl|rgb)a?\(\s*(?:(\s*\d{1,3}%?\s*),?){3}(\s*,[0-9.]{1,4})?\))|)$/i;
|
||||
const colorMap = new Map();
|
||||
|
||||
export function getColor(key) {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
if (colorMap.has(key)) {
|
||||
return colorMap.get(key);
|
||||
}
|
||||
|
||||
// check if the key is a css color
|
||||
if (typeof key === "string" && key.match(CSS_COLOR_REGEX)) {
|
||||
colorMap.set(key, key);
|
||||
} else if (typeof key === "number") {
|
||||
colorMap.set(key, ((key - 1) % 55) + 1);
|
||||
} else {
|
||||
colorMap.set(key, (((colorMap.size + 1) * 5) % 24) + 1);
|
||||
}
|
||||
|
||||
return colorMap.get(key);
|
||||
}
|
||||
|
|
@ -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" />`;
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.o-section-slide {
|
||||
max-height: 0;
|
||||
transition: max-height 0.35s ease;
|
||||
|
||||
&.o-section-slide-enter,
|
||||
&.o-section-slide-leave {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
&.o-section-slide-enter-active {
|
||||
max-height: 20rem; // fixed value is required to properly trigger transition
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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>
|
||||
143
odoo-bringout-oca-ocb-web/web/static/src/views/calendar/hooks.js
Normal file
143
odoo-bringout-oca-ocb-web/web/static/src/views/calendar/hooks.js
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
372
odoo-bringout-oca-ocb-web/web/static/src/views/debug_items.js
Normal file
372
odoo-bringout-oca-ocb-web/web/static/src/views/debug_items.js
Normal 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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_field_ace {
|
||||
display: block !important;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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">
|
||||
​ <!-- Zero width space needed to set height -->
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
body:not(.o_touch_device) .o_field_email {
|
||||
&:not(:hover):not(:focus-within) {
|
||||
& input:not(:hover) ~ a {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
303
odoo-bringout-oca-ocb-web/web/static/src/views/fields/field.js
Normal file
303
odoo-bringout-oca-ocb-web/web/static/src/views/fields/field.js
Normal 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: () => {} };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue