Initial commit: Odoo 18.0-20251222 extra-addons
This commit is contained in:
190
web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js
Executable file
190
web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js
Executable 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 [];
|
||||
}
|
||||
}
|
||||
166
web_timeline/static/src/views/timeline/timeline_canvas.esm.js
Executable file
166
web_timeline/static/src/views/timeline/timeline_canvas.esm.js
Executable 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;
|
||||
}
|
||||
}
|
||||
8
web_timeline/static/src/views/timeline/timeline_canvas.scss
Executable file
8
web_timeline/static/src/views/timeline/timeline_canvas.scss
Executable file
@@ -0,0 +1,8 @@
|
||||
.oe_timeline_view_canvas {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
271
web_timeline/static/src/views/timeline/timeline_controller.esm.js
Executable file
271
web_timeline/static/src/views/timeline/timeline_controller.esm.js
Executable 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,
|
||||
};
|
||||
18
web_timeline/static/src/views/timeline/timeline_controller.xml
Executable file
18
web_timeline/static/src/views/timeline/timeline_controller.xml
Executable 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>
|
||||
234
web_timeline/static/src/views/timeline/timeline_model.esm.js
Executable file
234
web_timeline/static/src/views/timeline/timeline_model.esm.js
Executable 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;
|
||||
}
|
||||
}
|
||||
486
web_timeline/static/src/views/timeline/timeline_renderer.esm.js
Executable file
486
web_timeline/static/src/views/timeline/timeline_renderer.esm.js
Executable 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,
|
||||
};
|
||||
46
web_timeline/static/src/views/timeline/timeline_renderer.xml
Executable file
46
web_timeline/static/src/views/timeline/timeline_renderer.xml
Executable 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>
|
||||
47
web_timeline/static/src/views/timeline/timeline_view.esm.js
Executable file
47
web_timeline/static/src/views/timeline/timeline_view.esm.js
Executable 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);
|
||||
28
web_timeline/static/src/views/timeline/timeline_view.scss
Executable file
28
web_timeline/static/src/views/timeline/timeline_view.scss
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user