Initial commit: Odoo 18.0-20251222 extra-addons
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
tests / Detect unreleased dependencies (push) Has been cancelled
tests / test with OCB (push) Has been cancelled
tests / test with Odoo (push) Has been cancelled

This commit is contained in:
tocmo0nlord
2026-03-13 20:43:25 +00:00
parent 36e847a7df
commit adbe430761
9472 changed files with 1265727 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
/**
* Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
*/
import {_t} from "@web/core/l10n/translation";
import {exprToBoolean} from "@web/core/utils/strings";
import {parseExpr} from "@web/core/py_js/py";
import {visitXML} from "@web/core/utils/xml";
const MODES = ["day", "week", "month", "fit"];
export class TimelineParseArchError extends Error {}
export class TimelineArchParser {
parse(arch, fields) {
const archInfo = {
colors: [],
class: "",
templateDocs: {},
min_height: 300,
mode: "fit",
canCreate: true,
canUpdate: true,
canDelete: true,
options: {
groupOrder: "order",
orientation: {axis: "both", item: "top"},
selectable: true,
multiselect: true,
showCurrentTime: true,
stack: true,
margin: {item: 2},
zoomKey: "ctrlKey",
},
};
const fieldNames = fields.display_name ? ["display_name"] : [];
visitXML(arch, (node) => {
switch (node.tagName) {
case "timeline": {
if (!node.hasAttribute("date_start")) {
throw new TimelineParseArchError(
_t("Timeline view has not defined 'date_start' attribute.")
);
}
if (!node.hasAttribute("default_group_by")) {
throw new TimelineParseArchError(
_t(
"Timeline view has not defined 'default_group_by' attribute."
)
);
}
archInfo.date_start = node.getAttribute("date_start");
archInfo.default_group_by = node.getAttribute("default_group_by");
if (node.hasAttribute("class")) {
archInfo.class = node.getAttribute("class");
}
if (node.hasAttribute("date_stop")) {
archInfo.date_stop = node.getAttribute("date_stop");
}
if (node.hasAttribute("date_delay")) {
archInfo.date_delay = node.getAttribute("date_delay");
}
if (node.hasAttribute("colors")) {
archInfo.colors = this.parse_colors(
node.getAttribute("colors")
);
}
if (node.hasAttribute("dependency_arrow")) {
archInfo.dependency_arrow =
node.getAttribute("dependency_arrow");
}
if (node.hasAttribute("stack")) {
archInfo.options.stack = exprToBoolean(
node.getAttribute("stack"),
true
);
}
if (node.hasAttribute("zoomKey")) {
archInfo.options.zoomKey =
node.getAttribute("zoomKey") || "ctrlKey";
}
if (node.hasAttribute("margin")) {
archInfo.options.margin = node.getAttribute("margin")
? JSON.parse(node.getAttribute("margin"))
: {item: 2};
}
if (node.hasAttribute("min_height")) {
archInfo.min_height = node.getAttribute("min_height");
}
if (node.hasAttribute("mode")) {
archInfo.mode = node.getAttribute("mode");
if (!MODES.includes(archInfo.mode)) {
throw new TimelineParseArchError(
`Timeline view cannot display mode: ${archInfo.mode}`
);
}
}
if (node.hasAttribute("event_open_popup")) {
archInfo.open_popup_action = exprToBoolean(
node.getAttribute("event_open_popup")
);
}
if (node.hasAttribute("create")) {
archInfo.canCreate = exprToBoolean(
node.getAttribute("create"),
true
);
}
if (node.hasAttribute("edit")) {
archInfo.canUpdate = exprToBoolean(
node.getAttribute("edit"),
true
);
}
if (node.hasAttribute("delete")) {
archInfo.canDelete = exprToBoolean(
node.getAttribute("delete"),
true
);
}
break;
}
case "field": {
const fieldName = node.getAttribute("name");
if (!fieldNames.includes(fieldName)) {
fieldNames.push(fieldName);
}
break;
}
case "t": {
if (node.hasAttribute("t-name")) {
archInfo.templateDocs[node.getAttribute("t-name")] = node;
break;
}
}
}
});
const fieldsToGather = [
"date_start",
"date_stop",
"default_group_by",
"progress",
"date_delay",
archInfo.default_group_by,
];
for (const field of fieldsToGather) {
if (archInfo[field] && !fieldNames.includes(archInfo[field])) {
fieldNames.push(archInfo[field]);
}
}
for (const color of archInfo.colors) {
if (!fieldNames.includes(color.field)) {
fieldNames.push(color.field);
}
}
if (
archInfo.dependency_arrow &&
!fieldNames.includes(archInfo.dependency_arrow)
) {
fieldNames.push(archInfo.dependency_arrow);
}
archInfo.fieldNames = fieldNames;
return archInfo;
}
/**
* Parse the colors attribute.
* @param {Array} colors
* @returns {Array}
*/
parse_colors(colors) {
if (colors) {
return colors
.split(";")
.filter(Boolean)
.map((color_pair) => {
const [color, expr] = color_pair.split(":");
const ast = parseExpr(expr);
return {
color: color,
field: ast.left.value,
ast,
};
});
}
return [];
}
}

View File

@@ -0,0 +1,166 @@
/* Copyright 2018 Onestein
Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */
/**
* Used to draw stuff on upon the timeline view.
*/
export class TimelineCanvas {
constructor(canvas_ref) {
this.canvas_ref = canvas_ref;
}
/**
* Clears all drawings (svg elements) from the canvas.
*/
clear() {
if (this.canvas_ref) {
const tempElement = document.createElement("div");
tempElement.innerHTML = this.canvas_ref;
Array.from(tempElement.children).forEach((child) => {
if (child.tagName.toLowerCase() !== "defs") {
child.remove();
}
});
this.canvas_ref = tempElement.innerHTML;
}
}
/**
* Gets the path from one point to another.
*
* @param {Object} rectFrom
* @param {Object} rectTo
* @param {Number} widthMarker The marker's width of the polyline
* @param {Number} breakAt The space between the line turns
* @returns {Array} Each item represents a coordinate
*/
get_polyline_points(rectFrom, rectTo, widthMarker, breakAt) {
let fromX = 0,
toX = 0;
if (rectFrom.x < rectTo.x + rectTo.w) {
fromX = rectFrom.x + rectFrom.w + widthMarker;
toX = rectTo.x;
} else {
fromX = rectFrom.x - widthMarker;
toX = rectTo.x + rectTo.w;
}
let deltaBreak = 0;
if (fromX < toX) {
deltaBreak = fromX + breakAt - (toX - breakAt);
} else {
deltaBreak = fromX - breakAt - (toX + breakAt);
}
const fromHalfHeight = rectFrom.h / 2;
const fromY = rectFrom.y + fromHalfHeight;
const toHalfHeight = rectTo.h / 2;
const toY = rectTo.y + toHalfHeight;
const xDiff = fromX - toX;
const yDiff = fromY - toY;
const threshold = breakAt + widthMarker;
const spaceY = toHalfHeight + 2;
const points = [[fromX, fromY]];
const _addPoints = (space, ePoint, mode) => {
if (mode) {
points.push([fromX + breakAt, fromY]);
points.push([fromX + breakAt, ePoint + space]);
points.push([toX - breakAt, toY + space]);
points.push([toX - breakAt, toY]);
} else {
points.push([fromX - breakAt, fromY]);
points.push([fromX - breakAt, ePoint + space]);
points.push([toX + breakAt, toY + space]);
points.push([toX + breakAt, toY]);
}
};
if (fromY !== toY) {
if (Math.abs(xDiff) < threshold) {
points.push([fromX + breakAt, toY + yDiff]);
points.push([fromX + breakAt, toY]);
} else {
const yDiffSpace = yDiff > 0 ? spaceY : -spaceY;
_addPoints(yDiffSpace, toY, rectFrom.x < rectTo.x + rectTo.w);
}
} else if (Math.abs(deltaBreak) >= threshold) {
_addPoints(spaceY, fromY, fromX < toX);
}
points.push([toX, toY]);
return points;
}
/**
* Draws an arrow.
*
* @param {HTMLElement} from Element to draw the arrow from
* @param {HTMLElement} to Element to draw the arrow to
* @param {String} color Color of the line
* @param {Number} width Width of the line
* @returns {HTMLElement} The created SVG polyline
*/
draw_arrow(from, to, color, width) {
return this.draw_line(from, to, color, width, "#arrowhead", 10, 12);
}
/**
* Draws a line.
*
* @param {HTMLElement} from Element to draw the line from
* @param {HTMLElement} to Element to draw the line to
* @param {String} color Color of the line
* @param {Number} width Width of the line
* @param {String} markerStart Start marker of the line
* @param {Number} widthMarker The marker's width of the polyline
* @param {Number} breakLineAt The space between the line turns
* @returns {HTMLElement} The created SVG polyline
*/
draw_line(
from,
to,
color = "#000",
width = 1,
markerStart,
widthMarker,
breakLineAt
) {
const fromElement =
typeof from === "string" ? document.querySelector(from) : from;
const toElement = typeof to === "string" ? document.querySelector(to) : to;
if (!fromElement || !toElement) return;
const childPosFrom = fromElement.getBoundingClientRect();
const parentFrom = fromElement.closest(".vis-center")?.getBoundingClientRect();
const rectFrom = {
x: childPosFrom.left - (parentFrom?.left || 0),
y: childPosFrom.top - (parentFrom?.top || 0),
w: fromElement.offsetWidth,
h: fromElement.offsetHeight,
};
const childPosTo = toElement.getBoundingClientRect();
const parentTo = toElement.closest(".vis-center")?.getBoundingClientRect();
const rectTo = {
x: childPosTo.left - (parentTo?.left || 0),
y: childPosTo.top - (parentTo?.top || 0),
w: toElement.offsetWidth,
h: toElement.offsetHeight,
};
const points = this.get_polyline_points(
rectFrom,
rectTo,
widthMarker,
breakLineAt
);
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
line.setAttribute("points", points.flat().join(","));
line.setAttribute("stroke", color);
line.setAttribute("stroke-width", width);
line.setAttribute("fill", "none");
if (markerStart) {
line.setAttribute("marker-start", `url(${markerStart})`);
}
if (this.canvas_ref instanceof HTMLElement) {
this.canvas_ref.appendChild(line);
}
return line;
}
}

View File

@@ -0,0 +1,8 @@
.oe_timeline_view_canvas {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,271 @@
/** @odoo-module alias=web_timeline.TimelineController **/
/**
* Copyright 2023 Onestein - Anjeel Haria
* Copyright 2024 Tecnativa - Carlos López
* License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
*/
import {Component, useRef} from "@odoo/owl";
import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog";
import {FormViewDialog} from "@web/views/view_dialogs/form_view_dialog";
import {Layout} from "@web/search/layout";
import {SearchBar} from "@web/search/search_bar/search_bar";
import {_t} from "@web/core/l10n/translation";
import {makeContext} from "@web/core/context";
import {standardViewProps} from "@web/views/standard_view_props";
import {useDebounced} from "@web/core/utils/timing";
import {useModel} from "@web/model/model";
import {useSearchBarToggler} from "@web/search/search_bar/search_bar_toggler";
import {useService} from "@web/core/utils/hooks";
import {useSetupAction} from "@web/search/action_hook";
const {DateTime} = luxon;
export class TimelineController extends Component {
/**
* @override
*/
setup() {
this.rootRef = useRef("root");
this.model = useModel(this.props.Model, this.props.modelParams);
useSetupAction({rootRef: useRef("root")});
this.searchBarToggler = useSearchBarToggler();
this.date_start = this.props.modelParams.date_start;
this.date_stop = this.props.modelParams.date_stop;
this.date_delay = this.props.modelParams.date_delay;
this.open_popup_action = this.props.modelParams.open_popup_action;
this.moveQueue = [];
this.debouncedInternalMove = useDebounced(this.internalMove, 0);
this.dialogService = useService("dialog");
this.actionService = useService("action");
}
get rendererProps() {
return {
model: this.model,
onAdd: this._onAdd.bind(this),
onGroupClick: this._onGroupClick.bind(this),
onItemDoubleClick: this._onItemDoubleClick.bind(this),
onMove: this._onMove.bind(this),
onRemove: this._onRemove.bind(this),
onUpdate: this._onUpdate.bind(this),
};
}
getSearchProps() {
const {comparision, context, domain, groupBy, orderBy} = this.env.searchModel;
return {comparision, context, domain, groupBy, orderBy};
}
/**
* Gets triggered when a group in the timeline is
* clicked (by the TimelineRenderer).
*
* @private
* @param {EventObject} item
*/
_onGroupClick(item) {
const groupField = this.model.last_group_bys[0];
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: this.model.fields[groupField].relation,
res_id: item.group,
views: [[false, "form"]],
view_mode: "form",
target: "new",
});
}
/**
* Triggered on double-click on an item in read-only mode (otherwise, we use _onUpdate).
*
* @private
* @param {EventObject} event
* @returns {jQuery.Deferred}
*/
_onItemDoubleClick(event) {
return this.openItem(event.item, false);
}
/**
* Opens a form view of a clicked timeline
* item (triggered by the TimelineRenderer).
*
* @private
* @param {Object} item
* @returns {Object}
*/
_onUpdate(item) {
const item_id = Number(item.evt.id) || item.evt.id;
return this.openItem(item_id, true);
}
/** Open specified item, either through modal, or by navigating to form view.
* @param {Integer} item_id
* @param {Boolean} is_editable
*/
openItem(item_id, is_editable) {
if (this.open_popup_action) {
const options = {
resModel: this.model.model_name,
resId: item_id,
};
if (is_editable) {
options.onRecordSaved = async () => {
await this.model.load(this.getSearchProps());
this.render();
};
} else {
options.preventEdit = true;
}
this.Dialog = this.dialogService.add(FormViewDialog, options, {});
} else {
this.env.services.action.switchView("form", {
resId: item_id,
mode: is_editable ? "edit" : "readonly",
});
}
}
/**
* Gets triggered when a timeline item is
* moved (triggered by the TimelineRenderer).
*
* @private
* @param {Object} item
* @param {Function} callback
*/
_onMove(item, callback) {
const event_start = DateTime.fromJSDate(item.start);
const event_end = item.end ? DateTime.fromJSDate(item.end) : false;
let group = false;
if (item.group !== -1) {
group = item.group;
}
const data = {};
// In case of a move event, the date_delay stay the same,
// only date_start and stop must be updated
data[this.date_start] = this.model.serializeDate(this.date_start, event_start);
if (this.date_stop) {
// In case of instantaneous event, item.end is not defined
if (event_end) {
data[this.date_stop] = this.model.serializeDate(
this.date_stop,
event_end
);
} else {
data[this.date_stop] = data[this.date_start];
}
}
if (this.date_delay && event_end) {
const diff = event_end.diff(event_start, "hours");
data[this.date_delay] = diff.hours;
}
const grouped_field = this.model.last_group_bys[0];
if (this.model.fields[grouped_field].type !== "many2many") {
data[grouped_field] = group;
}
this.moveQueue.push({
id: item.id,
data,
item,
callback,
});
this.debouncedInternalMove();
}
/**
* Write enqueued moves to Odoo. After all writes are finished it updates
* the view once (prevents flickering of the view when multiple timeline items
* are moved at once).
*
* @returns {jQuery.Deferred}
*/
async internalMove() {
const queues = this.moveQueue.slice();
this.moveQueue = [];
for (const item of queues) {
await this.model.write_completed(item.id, item.data);
item.callback(item.item);
}
await this.model.load(this.getSearchProps());
this.render();
}
/**
* Triggered when a timeline item gets removed from the view.
* Requires user confirmation before it gets actually deleted.
*
* @private
* @param {Object} item
* @param {Function} callback
*/
_onRemove(item, callback) {
this.dialogService.add(ConfirmationDialog, {
title: _t("Warning"),
body: _t("Are you sure you want to delete this record?"),
confirmLabel: _t("Confirm"),
cancelLabel: _t("Discard"),
confirm: async () => {
await this.model.remove_completed(item);
callback(item);
},
cancel: () => {
return;
},
});
}
/**
* Triggered when a timeline item gets added and opens a form view.
*
* @private
* @param {Object} item
* @param {Function} callback
*/
_onAdd(item, callback) {
// Initialize default values for creation
const context = {};
let item_start = false,
item_end = false;
item_start = DateTime.fromJSDate(item.start);
context[`default_${this.date_start}`] = this.model.serializeDate(
this.date_start,
item_start
);
if (this.date_delay) {
context[`default_${this.date_delay}`] = 1;
}
if (this.date_stop && item.end) {
item_end = DateTime.fromJSDate(item.end);
context[`default_${this.date_stop}`] = this.model.serializeDate(
this.date_stop,
item_end
);
}
if (this.date_delay && this.date_stop && item_end) {
const diff = item_end.diff(item_start, "hours");
context[`default_${this.date_delay}`] = diff.hours;
}
if (item.group > 0) {
context[`default_${this.model.last_group_bys[0]}`] = item.group;
}
// Show popup
this.dialogService.add(
FormViewDialog,
{
resId: false,
context: makeContext([context], this.env.searchModel.context),
onRecordSaved: async (record) => {
const new_record = await this.model.create_completed(record.resId);
callback(new_record);
},
resModel: this.model.model_name,
},
{onClose: () => callback()}
);
}
}
TimelineController.template = "web_timeline.TimelineView";
TimelineController.components = {Layout, SearchBar};
TimelineController.props = {
...standardViewProps,
Model: Function,
modelParams: Object,
Renderer: Function,
};

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_timeline.TimelineView">
<div t-att-class="props.className" t-ref="root">
<Layout
className="model.useSampleModel ? 'o_view_sample_data' : ''"
display="props.display"
>
<t t-set-slot="layout-actions">
<SearchBar t-if="searchBarToggler.state.showSearchBar" />
</t>
<t t-component="props.Renderer" t-props="rendererProps" />
</Layout>
</div>
</t>
</templates>

View File

@@ -0,0 +1,234 @@
/**
* Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
*/
import {serializeDate, serializeDateTime} from "@web/core/l10n/dates";
import {KanbanCompiler} from "@web/views/kanban/kanban_compiler";
import {KeepLast} from "@web/core/utils/concurrency";
import {Model} from "@web/model/model";
import {evaluate} from "@web/core/py_js/py";
import {onWillStart} from "@odoo/owl";
import {registry} from "@web/core/registry";
import {renderToString} from "@web/core/utils/render";
import {useViewCompiler} from "@web/views/view_compiler";
const {DateTime} = luxon;
const parsers = registry.category("parsers");
const formatters = registry.category("formatters");
export class TimelineModel extends Model {
setup(params) {
this.params = params;
this.model_name = params.resModel;
this.fields = this.params.fields;
this.date_start = this.params.date_start;
this.date_stop = this.params.date_stop;
this.date_delay = this.params.date_delay;
this.colors = this.params.colors;
this.last_group_bys = this.params.default_group_by.split(",");
const templates = useViewCompiler(KanbanCompiler, this.params.templateDocs);
this.recordTemplate = templates["timeline-item"];
this.keepLast = new KeepLast();
onWillStart(async () => {
this.write_right = await this.orm.call(
this.model_name,
"check_access_rights",
["write", false]
);
this.unlink_right = await this.orm.call(
this.model_name,
"check_access_rights",
["unlink", false]
);
this.create_right = await this.orm.call(
this.model_name,
"check_access_rights",
["create", false]
);
});
}
/**
* Read the records for the timeline.
* @param {Object} searchParams
*/
async load(searchParams) {
if (searchParams.groupBy && searchParams.groupBy.length) {
this.last_group_bys = searchParams.groupBy;
} else {
this.last_group_bys = this.params.default_group_by.split(",");
}
let fields = this.params.fieldNames;
fields = [...new Set(fields.concat(this.last_group_bys))];
// Avoid ordering by many2many fields
// because it is not supported by Odoo
// In the module sale_timesheet_timeline, it is used
// with default_group_by = task_user_ids
let field_to_order = this.params.default_group_by;
if (this.fields[field_to_order].type === "many2many") {
field_to_order = undefined;
}
this.data = await this.keepLast.add(
this.orm.call(this.model_name, "search_read", [], {
fields: fields,
domain: searchParams.domain,
order: field_to_order,
context: searchParams.context,
})
);
this.notify();
}
/**
* Transform Odoo event object to timeline event object.
*
* @param {Object} record
* @private
* @returns {Object}
*/
_event_data_transform(record) {
const [date_start, date_stop] = this._get_event_dates(record);
let group = record[this.last_group_bys[0]];
if (group && Array.isArray(group) && group.length > 0) {
group = group[0];
} else {
group = -1;
}
let colorToApply = false;
for (const color of this.colors) {
if (evaluate(color.ast, record)) {
colorToApply = color.color;
}
}
let content = record.display_name;
if (this.recordTemplate) {
content = this._render_timeline_item(record);
}
const timeline_item = {
start: date_start.toJSDate(),
content: content,
id: record.id,
order: record.order,
group: group,
evt: record,
style: `background-color: ${colorToApply};`,
};
// Only specify range end when there actually is one.
// ➔ Instantaneous events / those with inverted dates are displayed as points.
if (date_stop && DateTime.fromISO(date_start) < DateTime.fromISO(date_stop)) {
timeline_item.end = date_stop.toJSDate();
}
return timeline_item;
}
/**
* Get dates from given event
*
* @param {Object} record
* @returns {Object}
*/
_get_event_dates(record) {
let date_start = DateTime.now();
let date_stop = null;
const date_delay = record[this.date_delay] || false;
date_start = this.parseDate(
this.fields[this.date_start],
record[this.date_start]
);
if (this.date_stop && record[this.date_stop]) {
date_stop = this.parseDate(
this.fields[this.date_stop],
record[this.date_stop]
);
}
if (!date_stop && date_delay) {
date_stop = date_start.plus({hours: date_delay});
}
return [date_start, date_stop];
}
/**
* Parse Date or DateTime field
*
* @param {Object} field
* @param {Object} value
* @returns {DateTime} new_date in UTC timezone if field is datetime
*/
parseDate(field, value) {
let new_date = parsers.get(field.type)(value);
if (field.type === "datetime") {
new_date = new_date.setZone("UTC", {keepLocalTime: true});
}
return new_date;
}
/**
* Serializes a date or datetime value based on the field type.
* to send it to the server.
* @param {String} field_name - The field name.
* @param {DateTime} value - The value to be serialized, either a date or datetime.
* @returns {String} - The serialized date or datetime string.
*/
serializeDate(field_name, value) {
const field = this.fields[field_name];
return field.type === "date" ? serializeDate(value) : serializeDateTime(value);
}
/**
* Render timeline item template.
*
* @param {Object} record Record
* @private
* @returns {String} Rendered template
*/
_render_timeline_item(record) {
return renderToString(this.recordTemplate, {
record: record,
formatters,
parsers,
});
}
/**
* Triggered upon confirm of removing a record.
* @param {EventObject} event
* @returns {jQuery.Deferred}
*/
async remove_completed(event) {
await this.orm.call(this.model_name, "unlink", [[event.evt.id]]);
const unlink_index = this.data.findIndex((item) => item.id === event.evt.id);
if (unlink_index !== -1) {
this.data.splice(unlink_index, 1);
}
}
/**
* Triggered upon completion of a new record.
* Updates the timeline view with the new record.
*
* @param {RecordId} id
* @returns {jQuery.Deferred}
*/
async create_completed(id) {
const records = await this.orm.call(this.model_name, "read", [
[id],
this.params.fieldNames,
]);
return this._event_data_transform(records[0]);
}
/**
* Triggered upon completion of writing a record.
* @param {Integer} id
* @param {Object} vals
*/
async write_completed(id, vals) {
return this.orm.call(this.model_name, "write", [id, vals]);
}
get canCreate() {
return this.params.canCreate && this.create_right;
}
get canDelete() {
return this.params.canDelete && this.unlink_right;
}
get canEdit() {
return this.params.canUpdate && this.write_right;
}
}

View File

@@ -0,0 +1,486 @@
/* global vis */
/**
* Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
*/
import {
Component,
onMounted,
onWillStart,
onWillUpdateProps,
useRef,
useState,
} from "@odoo/owl";
import {TimelineCanvas} from "./timeline_canvas.esm";
import {_t} from "@web/core/l10n/translation";
import {loadBundle} from "@web/core/assets";
import {renderToString} from "@web/core/utils/render";
import {useService} from "@web/core/utils/hooks";
const {DateTime} = luxon;
export class TimelineRenderer extends Component {
setup() {
this.orm = useService("orm");
this.rootRef = useRef("root");
this.canvasRef = useRef("canvas");
this.model = this.props.model;
this.params = this.model.params;
this.mode = useState({data: this.params.mode});
this.options = this.params.options;
this.min_height = this.params.min_height;
this.date_start = this.params.date_start;
this.dependency_arrow = this.params.dependency_arrow;
this.fields = this.params.fields;
this.timeline = false;
this.initial_data_loaded = false;
this.canvas_ref = renderToString("TimelineView.Canvas", {});
onWillUpdateProps(async (props) => {
this.on_data_loaded(props.model.data);
});
onWillStart(async () => {
await loadBundle("web_timeline.vis-timeline_lib");
});
onMounted(() => {
// Prevent Double Rendering on Updates
if (!this.timeline) {
this.init_timeline();
}
this.on_attach_callback();
});
}
/**
* Triggered when the timeline is attached to the DOM.
*/
on_attach_callback() {
const $root = this.rootRef.el;
if (this.params.class) {
$root.classList.add(this.params.class);
}
if (this.rootRef.el) {
const parentHeight = this.rootRef.el.parentElement?.clientHeight || 0;
const buttonHeight =
this.rootRef.el.querySelector(".oe_timeline_buttons")?.clientHeight ||
0;
const height = parentHeight - buttonHeight;
if (height > this.min_height && this.timeline) {
this.timeline.setOptions({
height: height,
});
}
}
}
/**
* Set the timeline window to today (day).
*
* @private
*/
_onTodayClicked() {
this.mode.data = "today";
if (this.timeline) {
this.timeline.setWindow({
start: DateTime.now().toJSDate(),
end: DateTime.now().plus({hours: 24}).toJSDate(),
});
}
}
/**
* Scale the timeline window to a day.
*
* @private
*/
_onScaleDayClicked() {
this.mode.data = "day";
this._scaleCurrentWindow(() => 24);
}
/**
* Scale the timeline window to a week.
*
* @private
*/
_onScaleWeekClicked() {
this.mode.data = "week";
this._scaleCurrentWindow(() => 24 * 7);
}
/**
* Scale the timeline window to a month.
*
* @private
*/
_onScaleMonthClicked() {
this.mode.data = "month";
this._scaleCurrentWindow((start) => 24 * start.daysInMonth);
}
/**
* Scale the timeline window to a year.
*
* @private
*/
_onScaleYearClicked() {
this.mode.data = "year";
this._scaleCurrentWindow((start) => 24 * (start.isInLeapYear ? 366 : 365));
}
/**
* Scales the timeline window based on the current window.
*
* @param {Function} getHoursFromStart Function which returns the timespan
* (in hours) the window must be scaled to, starting from the "start" moment.
* @private
*/
_scaleCurrentWindow(getHoursFromStart) {
if (this.timeline) {
const start = DateTime.fromJSDate(this.timeline.getWindow().start);
const end = start.plus({hours: getHoursFromStart(start)});
this.timeline.setWindow(start.toJSDate(), end.toJSDate());
}
}
/**
* Computes the initial visible window.
*
* @private
*/
_computeMode() {
if (this.mode.data) {
let start = false,
end = false;
const current_date = DateTime.now();
switch (this.mode.data) {
case "day":
start = current_date.startOf("day");
end = current_date.endOf("day");
break;
case "week":
start = current_date.startOf("week");
end = current_date.endOf("week");
break;
case "month":
start = current_date.startOf("month");
end = current_date.endOf("month");
break;
}
if (end && start) {
this.options.start = start.toJSDate();
this.options.end = end.toJSDate();
} else {
this.mode.data = "fit";
}
}
}
/**
* Initializes the timeline
* (https://visjs.github.io/vis-timeline/docs/timeline).
*
* @private
*/
init_timeline() {
this._computeMode();
this.options.editable = {};
if (this.model.canEdit) {
this.options.onMove = this.on_move.bind(this);
this.options.onUpdate = this.on_update.bind(this);
// Drag items horizontally
this.options.editable.updateTime = true;
// Drag items from one group to another
this.options.editable.updateGroup = true;
if (this.model.canCreate) {
this.options.onAdd = this.on_add.bind(this);
// Add new items by double tapping
this.options.editable.add = true;
}
}
if (this.model.canDelete) {
this.options.onRemove = this.on_remove.bind(this);
// Delete an item by tapping the delete button top right
this.options.editable.remove = true;
}
// Configure XSS filtering options to mitigate potential security risks.
// Disabling XSS filtering can lead to vulnerabilities, as highlighted in:
// - CVE-2020-28487 (https://www.cve.org/CVERecord?id=CVE-2020-28487)
// - https://github.com/visjs/vis-timeline/pull/840
// The solution is to define a whitelist of allowed HTML elements and attributes.
// TODO: Check if this can be removed when this PR is merged: https://github.com/visjs/vis-timeline/pull/1860
this.options.xss = {
filterOptions: {
whiteList: this.getXSSWhiteList(),
},
};
this.timeline = new vis.Timeline(this.canvasRef.el, {}, this.options);
this.timeline.on("click", this.on_timeline_click.bind(this));
if (!this.options.onUpdate) {
// In read-only mode, catch double-clicks this way.
this.timeline.on("doubleClick", this.on_timeline_double_click.bind(this));
}
this.$centerContainer = this.timeline.dom.centerContainer;
this.canvas = new TimelineCanvas(this.canvas_ref);
if (this.$centerContainer.el) {
this.$centerContainer.el.appendChild(this.canvas_ref);
}
this.timeline.on("changed", () => {
this.draw_canvas();
this.load_initial_data();
});
}
/**
* Returns the XSS whitelist for the timeline library.
* This is used to filter out potentially harmful HTML elements and attributes.
* The white list allows only specific elements and attributes to be rendered.
* This is important for security reasons, as it helps prevent XSS attacks.
* @returns {Object} The XSS white list.
* Key: element name; value: array of allowed attributes.
*/
getXSSWhiteList() {
// Add more elements to the whitelist as needed.
return {
b: [],
div: ["class", "style"],
span: ["class", "name"],
small: ["class", "name"],
img: ["src", "width", "height", "alt", "loading", "class"],
};
}
/**
* Clears and draws the canvas items.
*
* @private
*/
draw_canvas() {
this.canvas.clear();
if (this.dependency_arrow) {
this.draw_dependencies();
}
}
/**
* Draw item dependencies on canvas.
*
* @private
*/
draw_dependencies() {
const items = this.timeline.itemSet.items;
const datas = this.timeline.itemsData;
if (!items || !datas) {
return;
}
const keys = Object.keys(items);
for (const key of keys) {
const item = items[key];
const data = datas.get(Number(key));
if (!data || !data.evt) {
return;
}
for (const id of data.evt[this.dependency_arrow]) {
if (keys.indexOf(id.toString()) !== -1) {
this.draw_dependency(item, items[id]);
}
}
}
}
/**
* Draws a dependency arrow between 2 timeline items.
*
* @param {Object} from Start timeline item
* @param {Object} to Destination timeline item
* @param {Object} options
* @param {Object} options.line_color Color of the line
* @param {Object} options.line_width The width of the line
* @private
*/
draw_dependency(from, to, options) {
if (!from.displayed || !to.displayed) {
return;
}
const defaults = Object.assign({line_color: "black", line_width: 1}, options);
this.canvas.draw_arrow(
from.dom.box,
to.dom.box,
defaults.line_color,
defaults.line_width
);
}
/* Load initial data. This is called once after each redraw; we only handle the first one.
* Deferring this initial load here avoids rendering issues. */
load_initial_data() {
if (!this.initial_data_loaded) {
this.on_data_loaded(this.model.data);
this.initial_data_loaded = true;
this.timeline.redraw();
}
}
/**
* Set groups and events.
*
* @param {Object[]} records
* @param {Boolean} adjust_window
* @private
*/
async on_data_loaded(records, adjust_window) {
const data = [];
for (const record of records) {
if (record[this.date_start]) {
data.push(this.model._event_data_transform(record));
}
}
const groups = await this.split_groups(records);
this.timeline.setGroups(groups);
this.timeline.setItems(data);
const mode = !this.mode.data || this.mode.data === "fit";
const adjust = typeof adjust_window === "undefined" || adjust_window;
if (mode && adjust) {
this.timeline.fit();
}
}
/**
* Get the groups.
*
* @param {Object[]} records
* @private
* @returns {Array}
*/
async split_groups(records) {
if (this.model.last_group_bys.length === 0) {
return records;
}
const groups = [];
groups.push({id: -1, content: _t("<b>UNASSIGNED</b>"), order: -1});
var seq = 1;
for (const evt of records) {
const grouped_field = this.model.last_group_bys[0];
const group_name = evt[grouped_field];
if (group_name && group_name instanceof Array) {
const group = groups.find(
(existing_group) => existing_group.id === group_name[0]
);
if (group) {
continue;
}
// Check if group is m2m in this case add id -> value of all
// found entries.
if (this.fields[grouped_field].type === "many2many") {
const list_values = await this.get_m2m_grouping_datas(
this.fields[grouped_field].relation,
group_name
);
for (const vals of list_values) {
const is_inside = groups.some((gr) => gr.id === vals.id);
if (!is_inside) {
vals.order = seq;
seq += 1;
groups.push(vals);
}
}
} else {
groups.push({
id: group_name[0],
content: group_name[1],
order: seq,
});
seq += 1;
}
}
}
return groups;
}
async get_m2m_grouping_datas(model, group_name) {
const groups = [];
for (const gr of group_name) {
const record_info = await this.orm.call(model, "read", [
gr,
["display_name"],
]);
groups.push({id: record_info[0].id, content: record_info[0].display_name});
}
return groups;
}
/**
* Handle a click within the timeline.
*
* @param {Object} e
* @private
*/
on_timeline_click(e) {
if (e.what === "group-label" && e.group !== -1) {
this.props.onGroupClick(e);
}
}
/**
* Handle a double-click within the timeline.
*
* @param {Object} e
* @private
*/
on_timeline_double_click(e) {
if (e.what === "item" && e.item !== -1) {
this.props.onItemDoubleClick(e);
}
}
/**
* Trigger onUpdate.
*
* @param {Object} item
* @private
*/
on_update(item) {
this.props.onUpdate(item);
}
/**
* Trigger onMove.
*
* @param {Object} item
* @param {Function} callback
* @private
*/
on_move(item, callback) {
this.props.onMove(item, callback);
}
/**
* Trigger onRemove.
*
* @param {Object} item
* @param {Function} callback
* @private
*/
on_remove(item, callback) {
this.props.onRemove(item, callback);
}
/**
* Trigger onAdd.
*
* @param {Object} item
* @param {Function} callback
* @private
*/
on_add(item, callback) {
this.props.onAdd(item, callback);
}
}
TimelineRenderer.template = "web_timeline.TimelineRenderer";
TimelineRenderer.props = {
model: Object,
onAdd: Function,
onGroupClick: Function,
onItemDoubleClick: Function,
onMove: Function,
onRemove: Function,
onUpdate: Function,
};

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_timeline.TimelineRenderer">
<div class="oe_timeline_view" t-ref="root">
<div class="oe_timeline_buttons">
<button
t-att-class="'oe_timeline_button_today btn ' + (mode.data == 'today' ? ' btn-primary' : 'btn-default')"
t-on-click="_onTodayClicked"
>Today</button>
<div class="btn-group btn-sm">
<button
t-att-class="'oe_timeline_button_scale_day btn ' + (mode.data == 'day' ? ' btn-primary' : 'btn-default')"
t-on-click="_onScaleDayClicked"
>Day</button>
<button
t-att-class="'oe_timeline_button_scale_week btn ' + (mode.data == 'week' ? ' btn-primary' : 'btn-default')"
t-on-click="_onScaleWeekClicked"
>Week</button>
<button
t-att-class="'oe_timeline_button_scale_month btn ' + (mode.data == 'month' ? ' btn-primary' : 'btn-default')"
t-on-click="_onScaleMonthClicked"
>Month</button>
<button
t-att-class="'oe_timeline_button_scale_year btn ' + (mode.data == 'year' ? ' btn-primary' : 'btn-default')"
t-on-click="_onScaleYearClicked"
>Year</button>
</div>
</div>
<div class="oe_timeline_widget" t-ref="canvas" />
</div>
</t>
<svg t-name="TimelineView.Canvas" class="oe_timeline_view_canvas">
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="10 0, 10 7, 0 3.5" />
</marker>
</defs>
</svg>
</templates>

View File

@@ -0,0 +1,47 @@
/* Odoo web_timeline
* Copyright 2015 ACSONE SA/NV
* Copyright 2016 Pedro M. Baeza <pedro.baeza@tecnativa.com>
* Copyright 2023 Onestein - Anjeel Haria
* Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
import {TimelineArchParser} from "./timeline_arch_parser.esm";
import {TimelineController} from "./timeline_controller.esm";
import {TimelineModel} from "./timeline_model.esm";
import {TimelineRenderer} from "./timeline_renderer.esm";
import {_t} from "@web/core/l10n/translation";
import {registry} from "@web/core/registry";
const viewRegistry = registry.category("views");
export const TimelineView = {
display_name: _t("Timeline"),
icon: "fa fa-tasks",
multiRecord: true,
ArchParser: TimelineArchParser,
Controller: TimelineController,
Renderer: TimelineRenderer,
Model: TimelineModel,
jsLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js"],
cssLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css"],
type: "timeline",
props: (genericProps, view) => {
const {arch, fields, resModel} = genericProps;
const parser = new view.ArchParser();
const archInfo = parser.parse(arch, fields);
const modelParams = {
...archInfo,
resModel: resModel,
fields: fields,
};
return {
...genericProps,
modelParams,
Model: view.Model,
Renderer: view.Renderer,
};
},
};
viewRegistry.add("timeline", TimelineView);

View File

@@ -0,0 +1,28 @@
$vis-hover-background-color: linen;
$vis-weekend-background-color: #dcdcdc;
$vis-item-content-padding: 0 3px !important;
.oe_timeline_view .vis-timeline {
.vis-grid {
&.vis-saturday,
&.vis-sunday {
background: $vis-weekend-background-color;
}
}
.vis-item {
&:hover {
background-color: $vis-hover-background-color !important;
cursor: pointer !important;
}
.vis-item-overflow {
overflow: visible;
}
.vis-item-content {
padding: $vis-item-content-padding;
&:hover {
background-color: $vis-hover-background-color !important;
}
}
}
}