mirror of
https://github.com/OCA/web.git
synced 2025-02-22 13:21:25 +02:00
[MOVE] web_timeline: new folder structure according Odoo guideslines
This commit is contained in:
160
web_timeline/static/src/views/timeline/timeline_canvas.esm.js
Normal file
160
web_timeline/static/src/views/timeline/timeline_canvas.esm.js
Normal file
@@ -0,0 +1,160 @@
|
||||
/* Copyright 2018 Onestein
|
||||
* License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */
|
||||
|
||||
odoo.define("web_timeline.TimelineCanvas", function (require) {
|
||||
"use strict";
|
||||
const Widget = require("web.Widget");
|
||||
|
||||
/**
|
||||
* Used to draw stuff on upon the timeline view.
|
||||
*/
|
||||
const TimelineCanvas = Widget.extend({
|
||||
template: "TimelineView.Canvas",
|
||||
|
||||
/**
|
||||
* Clears all drawings (svg elements) from the canvas.
|
||||
*/
|
||||
clear: function () {
|
||||
this.$(" > :not(defs)").remove();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: function (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: function (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: function (
|
||||
from,
|
||||
to,
|
||||
color,
|
||||
width,
|
||||
markerStart,
|
||||
widthMarker,
|
||||
breakLineAt
|
||||
) {
|
||||
const $from = $(from);
|
||||
const childPosFrom = $from.offset();
|
||||
const parentPosFrom = $from.closest(".vis-center").offset();
|
||||
const rectFrom = {
|
||||
x: childPosFrom.left - parentPosFrom.left,
|
||||
y: childPosFrom.top - parentPosFrom.top,
|
||||
w: $from.width(),
|
||||
h: $from.height(),
|
||||
};
|
||||
const $to = $(to);
|
||||
const childPosTo = $to.offset();
|
||||
const parentPosTo = $to.closest(".vis-center").offset();
|
||||
const rectTo = {
|
||||
x: childPosTo.left - parentPosTo.left,
|
||||
y: childPosTo.top - parentPosTo.top,
|
||||
w: $to.width(),
|
||||
h: $to.height(),
|
||||
};
|
||||
const points = this.get_polyline_points(
|
||||
rectFrom,
|
||||
rectTo,
|
||||
widthMarker,
|
||||
breakLineAt
|
||||
);
|
||||
const line = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"polyline"
|
||||
);
|
||||
line.setAttribute("points", _.flatten(points).join(","));
|
||||
line.setAttribute("stroke", color || "#000");
|
||||
line.setAttribute("stroke-width", width || 1);
|
||||
line.setAttribute("fill", "none");
|
||||
if (markerStart) {
|
||||
line.setAttribute("marker-start", "url(" + markerStart + ")");
|
||||
}
|
||||
this.$el.append(line);
|
||||
return line;
|
||||
},
|
||||
});
|
||||
|
||||
return TimelineCanvas;
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
.oe_timeline_view_canvas {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
17
web_timeline/static/src/views/timeline/timeline_canvas.xml
Normal file
17
web_timeline/static/src/views/timeline/timeline_canvas.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
@@ -0,0 +1,378 @@
|
||||
/** @odoo-module alias=web_timeline.TimelineController **/
|
||||
/* Copyright 2023 Onestein - Anjeel Haria
|
||||
* License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */
|
||||
import AbstractController from "web.AbstractController";
|
||||
import {FormViewDialog} from "@web/views/view_dialogs/form_view_dialog";
|
||||
import time from "web.time";
|
||||
import core from "web.core";
|
||||
import Dialog from "web.Dialog";
|
||||
var _t = core._t;
|
||||
import {Component} from "@odoo/owl";
|
||||
|
||||
export default AbstractController.extend({
|
||||
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
|
||||
onGroupClick: "_onGroupClick",
|
||||
onItemDoubleClick: "_onItemDoubleClick",
|
||||
onUpdate: "_onUpdate",
|
||||
onRemove: "_onRemove",
|
||||
onMove: "_onMove",
|
||||
onAdd: "_onAdd",
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.open_popup_action = params.open_popup_action;
|
||||
this.date_start = params.date_start;
|
||||
this.date_stop = params.date_stop;
|
||||
this.date_delay = params.date_delay;
|
||||
this.context = params.actionContext;
|
||||
this.moveQueue = [];
|
||||
this.debouncedInternalMove = _.debounce(this.internalMove, 0);
|
||||
},
|
||||
on_detach_callback() {
|
||||
if (this.Dialog) {
|
||||
this.Dialog();
|
||||
this.Dialog = undefined;
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
update: function (params, options) {
|
||||
const res = this._super.apply(this, arguments);
|
||||
if (_.isEmpty(params)) {
|
||||
return res;
|
||||
}
|
||||
const defaults = _.defaults({}, options, {
|
||||
adjust_window: true,
|
||||
});
|
||||
const domains = params.domain || this.renderer.last_domains || [];
|
||||
const contexts = params.context || [];
|
||||
const group_bys = params.groupBy || this.renderer.last_group_bys || [];
|
||||
this.last_domains = domains;
|
||||
this.last_contexts = contexts;
|
||||
// Select the group by
|
||||
let n_group_bys = group_bys;
|
||||
if (!n_group_bys.length && this.renderer.arch.attrs.default_group_by) {
|
||||
n_group_bys = this.renderer.arch.attrs.default_group_by.split(",");
|
||||
}
|
||||
this.renderer.last_group_bys = n_group_bys;
|
||||
this.renderer.last_domains = domains;
|
||||
|
||||
let fields = this.renderer.fieldNames;
|
||||
fields = _.uniq(fields.concat(n_group_bys));
|
||||
$.when(
|
||||
res,
|
||||
this._rpc({
|
||||
model: this.model.modelName,
|
||||
method: "search_read",
|
||||
kwargs: {
|
||||
fields: fields,
|
||||
domain: domains,
|
||||
order: [{name: this.renderer.arch.attrs.default_group_by}],
|
||||
},
|
||||
context: this.getSession().user_context,
|
||||
}).then((data) =>
|
||||
this.renderer.on_data_loaded(data, n_group_bys, defaults.adjust_window)
|
||||
)
|
||||
);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets triggered when a group in the timeline is
|
||||
* clicked (by the TimelineRenderer).
|
||||
*
|
||||
* @private
|
||||
* @param {EventObject} event
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
_onGroupClick: function (event) {
|
||||
const groupField = this.renderer.last_group_bys[0];
|
||||
return this.do_action({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: this.renderer.fields[groupField].relation,
|
||||
res_id: event.data.item.group,
|
||||
target: "new",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered on double-click on an item in read-only mode (otherwise, we use _onUpdate).
|
||||
*
|
||||
* @private
|
||||
* @param {EventObject} event
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
_onItemDoubleClick: function (event) {
|
||||
return this.openItem(event.data.item, false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a form view of a clicked timeline
|
||||
* item (triggered by the TimelineRenderer).
|
||||
*
|
||||
* @private
|
||||
* @param {EventObject} event
|
||||
*/
|
||||
_onUpdate: function (event) {
|
||||
const item = event.data.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. */
|
||||
openItem: function (item_id, is_editable) {
|
||||
if (this.open_popup_action) {
|
||||
const options = {
|
||||
resModel: this.model.modelName,
|
||||
resId: item_id,
|
||||
context: this.getSession().user_context,
|
||||
};
|
||||
if (is_editable) {
|
||||
options.onRecordSaved = () => this.write_completed();
|
||||
} else {
|
||||
options.preventEdit = true;
|
||||
}
|
||||
this.Dialog = Component.env.services.dialog.add(
|
||||
FormViewDialog,
|
||||
options,
|
||||
{}
|
||||
);
|
||||
} else {
|
||||
this.trigger_up("switch_view", {
|
||||
view_type: "form",
|
||||
model: this.model.modelName,
|
||||
res_id: item_id,
|
||||
mode: is_editable ? "edit" : "readonly",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets triggered when a timeline item is
|
||||
* moved (triggered by the TimelineRenderer).
|
||||
*
|
||||
* @private
|
||||
* @param {EventObject} event
|
||||
*/
|
||||
_onMove: function (event) {
|
||||
const item = event.data.item;
|
||||
const fields = this.renderer.fields;
|
||||
const event_start = item.start;
|
||||
const event_end = item.end;
|
||||
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] = time.auto_date_to_str(
|
||||
event_start,
|
||||
fields[this.date_start].type
|
||||
);
|
||||
if (this.date_stop) {
|
||||
// In case of instantaneous event, item.end is not defined
|
||||
if (event_end) {
|
||||
data[this.date_stop] = time.auto_date_to_str(
|
||||
event_end,
|
||||
fields[this.date_stop].type
|
||||
);
|
||||
} else {
|
||||
data[this.date_stop] = data[this.date_start];
|
||||
}
|
||||
}
|
||||
if (this.date_delay && event_end) {
|
||||
const diff_seconds = Math.round(
|
||||
(event_end.getTime() - event_start.getTime()) / 1000
|
||||
);
|
||||
data[this.date_delay] = diff_seconds / 3600;
|
||||
}
|
||||
const grouped_field = this.renderer.last_group_bys[0];
|
||||
this._rpc({
|
||||
model: this.modelName,
|
||||
method: "fields_get",
|
||||
args: [grouped_field],
|
||||
context: this.getSession().user_context,
|
||||
}).then(async (fields_processed) => {
|
||||
if (
|
||||
this.renderer.last_group_bys &&
|
||||
this.renderer.last_group_bys instanceof Array &&
|
||||
fields_processed[grouped_field].type !== "many2many"
|
||||
) {
|
||||
data[this.renderer.last_group_bys[0]] = group;
|
||||
}
|
||||
|
||||
this.moveQueue.push({
|
||||
id: event.data.item.id,
|
||||
data: data,
|
||||
event: event,
|
||||
});
|
||||
|
||||
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}
|
||||
*/
|
||||
internalMove: function () {
|
||||
const queues = this.moveQueue.slice();
|
||||
this.moveQueue = [];
|
||||
const defers = [];
|
||||
for (const item of queues) {
|
||||
defers.push(
|
||||
this._rpc({
|
||||
model: this.model.modelName,
|
||||
method: "write",
|
||||
args: [[item.event.data.item.id], item.data],
|
||||
context: this.getSession().user_context,
|
||||
}).then(() => {
|
||||
item.event.data.callback(item.event.data.item);
|
||||
})
|
||||
);
|
||||
}
|
||||
return $.when.apply($, defers).done(() => {
|
||||
this.write_completed({
|
||||
adjust_window: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered when a timeline item gets removed from the view.
|
||||
* Requires user confirmation before it gets actually deleted.
|
||||
*
|
||||
* @private
|
||||
* @param {EventObject} event
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
_onRemove: function (event) {
|
||||
var def = $.Deferred();
|
||||
|
||||
Dialog.confirm(this, _t("Are you sure you want to delete this record?"), {
|
||||
title: _t("Warning"),
|
||||
confirm_callback: () => {
|
||||
this.remove_completed(event).then(def.resolve.bind(def));
|
||||
},
|
||||
cancel_callback: def.resolve.bind(def),
|
||||
});
|
||||
|
||||
return def;
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered when a timeline item gets added and opens a form view.
|
||||
*
|
||||
* @private
|
||||
* @param {EventObject} event
|
||||
* @returns {dialogs.FormViewDialog}
|
||||
*/
|
||||
_onAdd: function (event) {
|
||||
const item = event.data.item;
|
||||
// Initialize default values for creation
|
||||
const default_context = {};
|
||||
default_context["default_".concat(this.date_start)] = item.start;
|
||||
if (this.date_delay) {
|
||||
default_context["default_".concat(this.date_delay)] = 1;
|
||||
}
|
||||
if (this.date_start) {
|
||||
default_context["default_".concat(this.date_start)] = moment(item.start)
|
||||
.utc()
|
||||
.format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
if (this.date_stop && item.end) {
|
||||
default_context["default_".concat(this.date_stop)] = moment(item.end)
|
||||
.utc()
|
||||
.format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
if (this.date_delay && this.date_start && this.date_stop && item.end) {
|
||||
default_context["default_".concat(this.date_delay)] =
|
||||
(moment(item.end) - moment(item.start)) / 3600000;
|
||||
}
|
||||
if (item.group > 0) {
|
||||
default_context["default_".concat(this.renderer.last_group_bys[0])] =
|
||||
item.group;
|
||||
}
|
||||
// Show popup
|
||||
this.Dialog = Component.env.services.dialog.add(
|
||||
FormViewDialog,
|
||||
{
|
||||
resId: false,
|
||||
context: _.extend(default_context, this.context),
|
||||
onRecordSaved: (record) => this.create_completed([record.res_id]),
|
||||
resModel: this.model.modelName,
|
||||
},
|
||||
{onClose: () => event.data.callback()}
|
||||
);
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered upon completion of a new record.
|
||||
* Updates the timeline view with the new record.
|
||||
*
|
||||
* @param {RecordId} id
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
create_completed: function (id) {
|
||||
return this._rpc({
|
||||
model: this.model.modelName,
|
||||
method: "read",
|
||||
args: [id, this.model.fieldNames],
|
||||
context: this.context,
|
||||
}).then((records) => {
|
||||
var new_event = this.renderer.event_data_transform(records[0]);
|
||||
var items = this.renderer.timeline.itemsData;
|
||||
items.add(new_event);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered upon completion of writing a record.
|
||||
* @param {ControllerOptions} options
|
||||
*/
|
||||
write_completed: function (options) {
|
||||
const params = {
|
||||
domain: this.renderer.last_domains,
|
||||
context: this.context,
|
||||
groupBy: this.renderer.last_group_bys,
|
||||
};
|
||||
this.update(params, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered upon confirm of removing a record.
|
||||
* @param {EventObject} event
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
remove_completed: function (event) {
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: "unlink",
|
||||
args: [[event.data.item.id]],
|
||||
context: this.getSession().user_context,
|
||||
}).then(() => {
|
||||
let unlink_index = false;
|
||||
for (var i = 0; i < this.model.data.data.length; i++) {
|
||||
if (this.model.data.data[i].id === event.data.item.id) {
|
||||
unlink_index = i;
|
||||
}
|
||||
}
|
||||
if (!isNaN(unlink_index)) {
|
||||
this.model.data.data.splice(unlink_index, 1);
|
||||
}
|
||||
event.data.callback(event.data.item);
|
||||
});
|
||||
},
|
||||
});
|
||||
77
web_timeline/static/src/views/timeline/timeline_model.esm.js
Normal file
77
web_timeline/static/src/views/timeline/timeline_model.esm.js
Normal file
@@ -0,0 +1,77 @@
|
||||
odoo.define("web_timeline.TimelineModel", function (require) {
|
||||
"use strict";
|
||||
|
||||
const AbstractModel = require("web.AbstractModel");
|
||||
|
||||
const TimelineModel = AbstractModel.extend({
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
load: function (params) {
|
||||
this.modelName = params.modelName;
|
||||
this.fieldNames = params.fieldNames;
|
||||
this.default_group_by = params.default_group_by;
|
||||
if (!this.preload_def) {
|
||||
this.preload_def = $.Deferred();
|
||||
$.when(
|
||||
this._rpc({
|
||||
model: this.modelName,
|
||||
method: "check_access_rights",
|
||||
args: ["write", false],
|
||||
}),
|
||||
this._rpc({
|
||||
model: this.modelName,
|
||||
method: "check_access_rights",
|
||||
args: ["unlink", false],
|
||||
}),
|
||||
this._rpc({
|
||||
model: this.modelName,
|
||||
method: "check_access_rights",
|
||||
args: ["create", false],
|
||||
})
|
||||
).then((write, unlink, create) => {
|
||||
this.write_right = write;
|
||||
this.unlink_right = unlink;
|
||||
this.create_right = create;
|
||||
this.preload_def.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
this.data = {
|
||||
domain: params.domain,
|
||||
context: params.context,
|
||||
};
|
||||
|
||||
return this.preload_def.then(this._loadTimeline.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the records for the timeline.
|
||||
*
|
||||
* @private
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
_loadTimeline: function () {
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: "search_read",
|
||||
kwargs: {
|
||||
fields: this.fieldNames,
|
||||
domain: this.data.domain,
|
||||
order: [{name: this.default_group_by}],
|
||||
context: this.data.context,
|
||||
},
|
||||
}).then((events) => {
|
||||
this.data.data = events;
|
||||
this.data.rights = {
|
||||
unlink: this.unlink_right,
|
||||
create: this.create_right,
|
||||
write: this.write_right,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return TimelineModel;
|
||||
});
|
||||
663
web_timeline/static/src/views/timeline/timeline_renderer.esm.js
Normal file
663
web_timeline/static/src/views/timeline/timeline_renderer.esm.js
Normal file
@@ -0,0 +1,663 @@
|
||||
/* global vis, py */
|
||||
odoo.define("web_timeline.TimelineRenderer", function (require) {
|
||||
"use strict";
|
||||
|
||||
const AbstractRenderer = require("web.AbstractRenderer");
|
||||
const core = require("web.core");
|
||||
const time = require("web.time");
|
||||
const utils = require("web.utils");
|
||||
const session = require("web.session");
|
||||
const QWeb = require("web.QWeb");
|
||||
const field_utils = require("web.field_utils");
|
||||
const TimelineCanvas = require("web_timeline.TimelineCanvas");
|
||||
|
||||
const _t = core._t;
|
||||
|
||||
const TimelineRenderer = AbstractRenderer.extend({
|
||||
template: "TimelineView",
|
||||
|
||||
events: _.extend({}, AbstractRenderer.prototype.events, {
|
||||
"click .oe_timeline_button_today": "_onTodayClicked",
|
||||
"click .oe_timeline_button_scale_day": "_onScaleDayClicked",
|
||||
"click .oe_timeline_button_scale_week": "_onScaleWeekClicked",
|
||||
"click .oe_timeline_button_scale_month": "_onScaleMonthClicked",
|
||||
"click .oe_timeline_button_scale_year": "_onScaleYearClicked",
|
||||
}),
|
||||
|
||||
init: function (parent, state, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.modelName = params.model;
|
||||
this.mode = params.mode;
|
||||
this.options = params.options;
|
||||
this.can_create = params.can_create;
|
||||
this.can_update = params.can_update;
|
||||
this.can_delete = params.can_delete;
|
||||
this.min_height = params.min_height;
|
||||
this.date_start = params.date_start;
|
||||
this.date_stop = params.date_stop;
|
||||
this.date_delay = params.date_delay;
|
||||
this.colors = params.colors;
|
||||
this.fieldNames = params.fieldNames;
|
||||
this.default_group_by = params.default_group_by;
|
||||
this.dependency_arrow = params.dependency_arrow;
|
||||
this.modelClass = params.view.model;
|
||||
this.fields = params.fields;
|
||||
|
||||
this.timeline = false;
|
||||
this.initial_data_loaded = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
const attrs = this.arch.attrs;
|
||||
this.$el.addClass(attrs.class);
|
||||
this.$timeline = this.$(".oe_timeline_widget");
|
||||
|
||||
if (!this.date_start) {
|
||||
throw new Error(
|
||||
_t("Timeline view has not defined 'date_start' attribute.")
|
||||
);
|
||||
}
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered when the timeline is attached to the DOM.
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
const height =
|
||||
this.$el.parent().height() - this.$(".oe_timeline_buttons").height();
|
||||
if (height > this.min_height && this.timeline) {
|
||||
this.timeline.setOptions({
|
||||
height: height,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_render: function () {
|
||||
return Promise.resolve().then(() => {
|
||||
// Prevent Double Rendering on Updates
|
||||
if (!this.timeline) {
|
||||
this.init_timeline();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the timeline window to today (day).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onTodayClicked: function () {
|
||||
if (this.timeline) {
|
||||
this.timeline.setWindow({
|
||||
start: new moment(),
|
||||
end: new moment().add(24, "hours"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Scale the timeline window to a day.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onScaleDayClicked: function () {
|
||||
this._scaleCurrentWindow(() => 24);
|
||||
},
|
||||
|
||||
/**
|
||||
* Scale the timeline window to a week.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onScaleWeekClicked: function () {
|
||||
this._scaleCurrentWindow(() => 24 * 7);
|
||||
},
|
||||
|
||||
/**
|
||||
* Scale the timeline window to a month.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onScaleMonthClicked: function () {
|
||||
this._scaleCurrentWindow((start) => 24 * moment(start).daysInMonth());
|
||||
},
|
||||
|
||||
/**
|
||||
* Scale the timeline window to a year.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onScaleYearClicked: function () {
|
||||
this._scaleCurrentWindow(
|
||||
(start) => 24 * (moment(start).isLeapYear() ? 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: function (getHoursFromStart) {
|
||||
if (this.timeline) {
|
||||
const start = this.timeline.getWindow().start;
|
||||
const end = moment(start).add(getHoursFromStart(start), "hours");
|
||||
this.timeline.setWindow(start, end);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Computes the initial visible window.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_computeMode: function () {
|
||||
if (this.mode) {
|
||||
let start = false,
|
||||
end = false;
|
||||
switch (this.mode) {
|
||||
case "day":
|
||||
start = new moment().startOf("day");
|
||||
end = new moment().endOf("day");
|
||||
break;
|
||||
case "week":
|
||||
start = new moment().startOf("week");
|
||||
end = new moment().endOf("week");
|
||||
break;
|
||||
case "month":
|
||||
start = new moment().startOf("month");
|
||||
end = new moment().endOf("month");
|
||||
break;
|
||||
}
|
||||
if (end && start) {
|
||||
this.options.start = start;
|
||||
this.options.end = end;
|
||||
} else {
|
||||
this.mode = "fit";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initializes the timeline
|
||||
* (https://visjs.github.io/vis-timeline/docs/timeline).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
init_timeline: function () {
|
||||
this._computeMode();
|
||||
this.options.editable = {};
|
||||
if (this.can_update && this.modelClass.data.rights.write) {
|
||||
this.options.onMove = this.on_move;
|
||||
this.options.onUpdate = this.on_update;
|
||||
// Drag items horizontally
|
||||
this.options.editable.updateTime = true;
|
||||
// Drag items from one group to another
|
||||
this.options.editable.updateGroup = true;
|
||||
if (this.can_create && this.modelClass.data.rights.create) {
|
||||
this.options.onAdd = this.on_add;
|
||||
// Add new items by double tapping
|
||||
this.options.editable.add = true;
|
||||
}
|
||||
}
|
||||
if (this.can_delete && this.modelClass.data.rights.unlink) {
|
||||
this.options.onRemove = this.on_remove;
|
||||
// Delete an item by tapping the delete button top right
|
||||
this.options.editable.remove = true;
|
||||
}
|
||||
this.options.xss = {disabled: true};
|
||||
this.qweb = new QWeb(session.debug, {_s: session.origin}, false);
|
||||
if (this.arch.children.length) {
|
||||
const tmpl = utils.json_node_to_xml(
|
||||
_.filter(this.arch.children, (item) => item.tag === "templates")[0]
|
||||
);
|
||||
this.qweb.add_template(tmpl);
|
||||
}
|
||||
|
||||
this.timeline = new vis.Timeline(this.$timeline.get(0), {}, this.options);
|
||||
this.timeline.on("click", this.on_timeline_click);
|
||||
if (!this.options.onUpdate) {
|
||||
// In read-only mode, catch double-clicks this way.
|
||||
this.timeline.on("doubleClick", this.on_timeline_double_click);
|
||||
}
|
||||
const group_bys = this.arch.attrs.default_group_by.split(",");
|
||||
this.last_group_bys = group_bys;
|
||||
this.last_domains = this.modelClass.data.domain;
|
||||
this.$centerContainer = $(this.timeline.dom.centerContainer);
|
||||
this.canvas = new TimelineCanvas(this);
|
||||
this.canvas.appendTo(this.$centerContainer);
|
||||
this.timeline.on("changed", () => {
|
||||
this.draw_canvas();
|
||||
this.load_initial_data();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears and draws the canvas items.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
draw_canvas: function () {
|
||||
this.canvas.clear();
|
||||
if (this.dependency_arrow) {
|
||||
this.draw_dependencies();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw item dependencies on canvas.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
draw_dependencies: function () {
|
||||
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: function (from, to, options) {
|
||||
if (!from.displayed || !to.displayed) {
|
||||
return;
|
||||
}
|
||||
const defaults = _.defaults({}, options, {
|
||||
line_color: "black",
|
||||
line_width: 1,
|
||||
});
|
||||
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: function () {
|
||||
if (!this.initial_data_loaded) {
|
||||
this.on_data_loaded(this.modelClass.data.data, this.last_group_bys);
|
||||
this.initial_data_loaded = true;
|
||||
this.timeline.redraw();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load display_name of records.
|
||||
*
|
||||
* @param {Object[]} events
|
||||
* @param {String[]} group_bys
|
||||
* @param {Boolean} adjust_window
|
||||
* @private
|
||||
* @returns {jQuery.Deferred}
|
||||
*/
|
||||
on_data_loaded: function (events, group_bys, adjust_window) {
|
||||
const ids = _.pluck(events, "id");
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: "name_get",
|
||||
args: [ids],
|
||||
context: this.getSession().user_context,
|
||||
}).then((names) => {
|
||||
const nevents = _.map(events, (event) =>
|
||||
_.extend(
|
||||
{
|
||||
__name: _.detect(names, (name) => name[0] === event.id)[1],
|
||||
},
|
||||
event
|
||||
)
|
||||
);
|
||||
return this.on_data_loaded_2(nevents, group_bys, adjust_window);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set groups and events.
|
||||
*
|
||||
* @param {Object[]} events
|
||||
* @param {String[]} group_bys
|
||||
* @param {Boolean} adjust_window
|
||||
* @private
|
||||
*/
|
||||
on_data_loaded_2: function (events, group_bys, adjust_window) {
|
||||
const data = [];
|
||||
this.grouped_by = group_bys;
|
||||
for (const evt of events) {
|
||||
if (evt[this.date_start]) {
|
||||
data.push(this.event_data_transform(evt));
|
||||
}
|
||||
}
|
||||
this.split_groups(events, group_bys).then((groups) => {
|
||||
this.timeline.setGroups(groups);
|
||||
this.timeline.setItems(data);
|
||||
const mode = !this.mode || this.mode === "fit";
|
||||
const adjust = _.isUndefined(adjust_window) || adjust_window;
|
||||
if (mode && adjust) {
|
||||
this.timeline.fit();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the groups.
|
||||
*
|
||||
* @param {Object[]} events
|
||||
* @param {String[]} group_bys
|
||||
* @private
|
||||
* @returns {Array}
|
||||
*/
|
||||
split_groups: async function (events, group_bys) {
|
||||
if (group_bys.length === 0) {
|
||||
return events;
|
||||
}
|
||||
const groups = [];
|
||||
groups.push({id: -1, content: _t("<b>UNASSIGNED</b>"), order: -1});
|
||||
var seq = 1;
|
||||
for (const evt of events) {
|
||||
const grouped_field = _.first(group_bys);
|
||||
const group_name = evt[grouped_field];
|
||||
if (group_name) {
|
||||
if (group_name instanceof Array) {
|
||||
const group = _.find(
|
||||
groups,
|
||||
(existing_group) => existing_group.id === group_name[0]
|
||||
);
|
||||
if (_.isUndefined(group)) {
|
||||
// Check if group is m2m in this case add id -> value of all
|
||||
// found entries.
|
||||
await this._rpc({
|
||||
model: this.modelName,
|
||||
method: "fields_get",
|
||||
args: [[grouped_field]],
|
||||
context: this.getSession().user_context,
|
||||
}).then(async (fields) => {
|
||||
if (fields[grouped_field].type === "many2many") {
|
||||
const list_values =
|
||||
await this.get_m2m_grouping_datas(
|
||||
fields[grouped_field].relation,
|
||||
group_name
|
||||
);
|
||||
for (const vals of list_values) {
|
||||
let is_inside = false;
|
||||
for (const gr of groups) {
|
||||
if (vals.id === gr.id) {
|
||||
is_inside = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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;
|
||||
},
|
||||
|
||||
get_m2m_grouping_datas: async function (model, group_name) {
|
||||
const groups = [];
|
||||
for (const gr of group_name) {
|
||||
await this._rpc({
|
||||
model: model,
|
||||
method: "name_get",
|
||||
args: [gr],
|
||||
context: this.getSession().user_context,
|
||||
}).then((name) => {
|
||||
groups.push({id: name[0][0], content: name[0][1]});
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get dates from given event
|
||||
*
|
||||
* @param {TransformEvent} evt
|
||||
* @returns {Object}
|
||||
*/
|
||||
_get_event_dates: function (evt) {
|
||||
let date_start = new moment();
|
||||
let date_stop = null;
|
||||
|
||||
const date_delay = evt[this.date_delay] || false,
|
||||
all_day = this.all_day ? evt[this.all_day] : false;
|
||||
|
||||
if (all_day) {
|
||||
date_start = time.auto_str_to_date(
|
||||
evt[this.date_start].split(" ")[0],
|
||||
"start"
|
||||
);
|
||||
if (this.no_period) {
|
||||
date_stop = date_start;
|
||||
} else {
|
||||
date_stop = this.date_stop
|
||||
? time.auto_str_to_date(
|
||||
evt[this.date_stop].split(" ")[0],
|
||||
"stop"
|
||||
)
|
||||
: null;
|
||||
}
|
||||
} else {
|
||||
date_start = time.auto_str_to_date(evt[this.date_start]);
|
||||
date_stop = this.date_stop
|
||||
? time.auto_str_to_date(evt[this.date_stop])
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!date_stop && date_delay) {
|
||||
date_stop = date_start.clone().add(date_delay, "hours").toDate();
|
||||
}
|
||||
|
||||
return [date_start, date_stop];
|
||||
},
|
||||
|
||||
/**
|
||||
* Transform Odoo event object to timeline event object.
|
||||
*
|
||||
* @param {TransformEvent} evt
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
event_data_transform: function (evt) {
|
||||
const [date_start, date_stop] = this._get_event_dates(evt);
|
||||
let group = evt[this.last_group_bys[0]];
|
||||
if (group && group instanceof Array && group.length > 0) {
|
||||
group = _.first(group);
|
||||
} else {
|
||||
group = -1;
|
||||
}
|
||||
|
||||
for (const color of this.colors) {
|
||||
if (py.eval(`'${evt[color.field]}' ${color.opt} '${color.value}'`)) {
|
||||
this.color = color.color;
|
||||
}
|
||||
}
|
||||
|
||||
let content = evt.__name || evt.display_name;
|
||||
if (this.arch.children.length) {
|
||||
content = this.render_timeline_item(evt);
|
||||
}
|
||||
|
||||
const r = {
|
||||
start: date_start,
|
||||
content: content,
|
||||
id: evt.id,
|
||||
order: evt.order,
|
||||
group: group,
|
||||
evt: evt,
|
||||
style: `background-color: ${this.color};`,
|
||||
};
|
||||
// Only specify range end when there actually is one.
|
||||
// ➔ Instantaneous events / those with inverted dates are displayed as points.
|
||||
if (date_stop && moment(date_start).isBefore(date_stop)) {
|
||||
r.end = date_stop;
|
||||
}
|
||||
this.color = null;
|
||||
return r;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render timeline item template.
|
||||
*
|
||||
* @param {Object} evt Record
|
||||
* @private
|
||||
* @returns {String} Rendered template
|
||||
*/
|
||||
render_timeline_item: function (evt) {
|
||||
if (this.qweb.has_template("timeline-item")) {
|
||||
return this.qweb.render("timeline-item", {
|
||||
record: evt,
|
||||
field_utils: field_utils,
|
||||
});
|
||||
}
|
||||
|
||||
console.error(
|
||||
_t('Template "timeline-item" not present in timeline view definition.')
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a click within the timeline.
|
||||
*
|
||||
* @param {ClickEvent} e
|
||||
* @private
|
||||
*/
|
||||
on_timeline_click: function (e) {
|
||||
if (e.what === "group-label" && e.group !== -1) {
|
||||
this._trigger(
|
||||
e,
|
||||
() => {
|
||||
// Do nothing
|
||||
},
|
||||
"onGroupClick"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a double-click within the timeline.
|
||||
*
|
||||
* @param {ClickEvent} e
|
||||
* @private
|
||||
*/
|
||||
on_timeline_double_click: function (e) {
|
||||
if (e.what === "item" && e.item !== -1) {
|
||||
this._trigger(
|
||||
e.item,
|
||||
() => {
|
||||
// No callback
|
||||
},
|
||||
"onItemDoubleClick"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger onUpdate.
|
||||
*
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
* @private
|
||||
*/
|
||||
on_update: function (item, callback) {
|
||||
this._trigger(item, callback, "onUpdate");
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger onMove.
|
||||
*
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
* @private
|
||||
*/
|
||||
on_move: function (item, callback) {
|
||||
this._trigger(item, callback, "onMove");
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger onRemove.
|
||||
*
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
* @private
|
||||
*/
|
||||
on_remove: function (item, callback) {
|
||||
this._trigger(item, callback, "onRemove");
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger onAdd.
|
||||
*
|
||||
* @param {Object} item
|
||||
* @param {Function} callback
|
||||
* @private
|
||||
*/
|
||||
on_add: function (item, callback) {
|
||||
this._trigger(item, callback, "onAdd");
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger_up encapsulation adds by default the renderer.
|
||||
*
|
||||
* @param {HTMLElement} item
|
||||
* @param {Function} callback
|
||||
* @param {String} trigger
|
||||
* @private
|
||||
*/
|
||||
_trigger: function (item, callback, trigger) {
|
||||
this.trigger_up(trigger, {
|
||||
item: item,
|
||||
callback: callback,
|
||||
renderer: this,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return TimelineRenderer;
|
||||
});
|
||||
174
web_timeline/static/src/views/timeline/timeline_view.esm.js
Normal file
174
web_timeline/static/src/views/timeline/timeline_view.esm.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/* global py */
|
||||
/* Odoo web_timeline
|
||||
* Copyright 2015 ACSONE SA/NV
|
||||
* Copyright 2016 Pedro M. Baeza <pedro.baeza@tecnativa.com>
|
||||
* Copyright 2023 Onestein - Anjeel Haria
|
||||
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
|
||||
|
||||
odoo.define("web_timeline.TimelineView", function (require) {
|
||||
"use strict";
|
||||
|
||||
const core = require("web.core");
|
||||
const utils = require("web.utils");
|
||||
const view_registry = require("web.view_registry");
|
||||
const AbstractView = require("web.AbstractView");
|
||||
const TimelineRenderer = require("web_timeline.TimelineRenderer");
|
||||
const TimelineController = require("web_timeline.TimelineController");
|
||||
const TimelineModel = require("web_timeline.TimelineModel");
|
||||
|
||||
const _lt = core._lt;
|
||||
|
||||
function isNullOrUndef(value) {
|
||||
return _.isUndefined(value) || _.isNull(value);
|
||||
}
|
||||
|
||||
function toBoolDefaultTrue(value) {
|
||||
return isNullOrUndef(value) ? true : utils.toBoolElse(value, true);
|
||||
}
|
||||
|
||||
var TimelineView = AbstractView.extend({
|
||||
display_name: _lt("Timeline"),
|
||||
icon: "fa fa-tasks",
|
||||
jsLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js"],
|
||||
cssLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css"],
|
||||
config: _.extend({}, AbstractView.prototype.config, {
|
||||
Model: TimelineModel,
|
||||
Controller: TimelineController,
|
||||
Renderer: TimelineRenderer,
|
||||
}),
|
||||
viewType: "timeline",
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function (viewInfo, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.modelName = this.controllerParams.modelName;
|
||||
|
||||
const action = params.action;
|
||||
this.arch = this.rendererParams.arch;
|
||||
const attrs = this.arch.attrs;
|
||||
const date_start = attrs.date_start;
|
||||
const date_stop = attrs.date_stop;
|
||||
const date_delay = attrs.date_delay;
|
||||
const dependency_arrow = attrs.dependency_arrow;
|
||||
|
||||
const fields = viewInfo.fields;
|
||||
let fieldNames = fields.display_name ? ["display_name"] : [];
|
||||
const fieldsToGather = [
|
||||
"date_start",
|
||||
"date_stop",
|
||||
"default_group_by",
|
||||
"progress",
|
||||
"date_delay",
|
||||
attrs.default_group_by,
|
||||
];
|
||||
|
||||
for (const field of fieldsToGather) {
|
||||
if (attrs[field]) {
|
||||
fieldNames.push(attrs[field]);
|
||||
}
|
||||
}
|
||||
|
||||
const archFieldNames = _.map(
|
||||
_.filter(this.arch.children, (item) => item.tag === "field"),
|
||||
(item) => item.attrs.name
|
||||
);
|
||||
fieldNames = _.union(fieldNames, archFieldNames);
|
||||
|
||||
const colors = this.parse_colors();
|
||||
for (const color of colors) {
|
||||
if (!fieldNames.includes(color.field)) {
|
||||
fieldNames.push(color.field);
|
||||
}
|
||||
}
|
||||
|
||||
if (dependency_arrow) {
|
||||
fieldNames.push(dependency_arrow);
|
||||
}
|
||||
|
||||
const mode = attrs.mode || attrs.default_window || "fit";
|
||||
const min_height = attrs.min_height || 300;
|
||||
|
||||
if (!isNullOrUndef(attrs.quick_create_instance)) {
|
||||
this.quick_create_instance = "instance." + attrs.quick_create_instance;
|
||||
}
|
||||
let open_popup_action = false;
|
||||
if (
|
||||
!isNullOrUndef(attrs.event_open_popup) &&
|
||||
utils.toBoolElse(attrs.event_open_popup, true)
|
||||
) {
|
||||
open_popup_action = attrs.event_open_popup;
|
||||
}
|
||||
this.rendererParams.mode = mode;
|
||||
this.rendererParams.model = this.modelName;
|
||||
this.rendererParams.view = this;
|
||||
this.rendererParams.options = this._preapre_vis_timeline_options(attrs);
|
||||
this.rendererParams.can_create = toBoolDefaultTrue(attrs.create);
|
||||
this.rendererParams.can_update = toBoolDefaultTrue(attrs.edit);
|
||||
this.rendererParams.can_delete = toBoolDefaultTrue(attrs.delete);
|
||||
this.rendererParams.date_start = date_start;
|
||||
this.rendererParams.date_stop = date_stop;
|
||||
this.rendererParams.date_delay = date_delay;
|
||||
this.rendererParams.colors = colors;
|
||||
this.rendererParams.fieldNames = fieldNames;
|
||||
this.rendererParams.default_group_by = attrs.default_group_by;
|
||||
this.rendererParams.min_height = min_height;
|
||||
this.rendererParams.dependency_arrow = dependency_arrow;
|
||||
this.rendererParams.fields = fields;
|
||||
this.loadParams.modelName = this.modelName;
|
||||
this.loadParams.fieldNames = fieldNames;
|
||||
this.loadParams.default_group_by = attrs.default_group_by;
|
||||
this.controllerParams.open_popup_action = open_popup_action;
|
||||
this.controllerParams.date_start = date_start;
|
||||
this.controllerParams.date_stop = date_stop;
|
||||
this.controllerParams.date_delay = date_delay;
|
||||
this.controllerParams.actionContext = action.context;
|
||||
this.withSearchPanel = false;
|
||||
},
|
||||
|
||||
_preapre_vis_timeline_options: function (attrs) {
|
||||
return {
|
||||
groupOrder: "order",
|
||||
orientation: {axis: "both", item: "top"},
|
||||
selectable: true,
|
||||
multiselect: true,
|
||||
showCurrentTime: true,
|
||||
stack: toBoolDefaultTrue(attrs.stack),
|
||||
margin: attrs.margin ? JSON.parse(attrs.margin) : {item: 2},
|
||||
zoomKey: attrs.zoomKey || "ctrlKey",
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse the colors attribute.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array}
|
||||
*/
|
||||
parse_colors: function () {
|
||||
if (this.arch.attrs.colors) {
|
||||
return _(this.arch.attrs.colors.split(";"))
|
||||
.chain()
|
||||
.compact()
|
||||
.map((color_pair) => {
|
||||
const pair = color_pair.split(":");
|
||||
const color = pair[0];
|
||||
const expr = pair[1];
|
||||
const temp = py.parse(py.tokenize(expr));
|
||||
return {
|
||||
color: color,
|
||||
field: temp.expressions[0].value,
|
||||
opt: temp.operators[0],
|
||||
value: temp.expressions[1].value,
|
||||
};
|
||||
})
|
||||
.value();
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
view_registry.add("timeline", TimelineView);
|
||||
return TimelineView;
|
||||
});
|
||||
28
web_timeline/static/src/views/timeline/timeline_view.scss
Normal file
28
web_timeline/static/src/views/timeline/timeline_view.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
web_timeline/static/src/views/timeline/timeline_view.xml
Normal file
27
web_timeline/static/src/views/timeline/timeline_view.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<template>
|
||||
<t t-name="TimelineView">
|
||||
<div class="oe_timeline_view">
|
||||
<div class="oe_timeline_buttons">
|
||||
<button
|
||||
class="btn btn-default btn-sm oe_timeline_button_today"
|
||||
>Today</button>
|
||||
<div class="btn-group btn-sm">
|
||||
<button
|
||||
class="btn btn-default oe_timeline_button_scale_day"
|
||||
>Day</button>
|
||||
<button
|
||||
class="btn btn-default oe_timeline_button_scale_week"
|
||||
>Week</button>
|
||||
<button
|
||||
class="btn btn-default oe_timeline_button_scale_month"
|
||||
>Month</button>
|
||||
<button
|
||||
class="btn btn-default oe_timeline_button_scale_year"
|
||||
>Year</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oe_timeline_widget" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
Reference in New Issue
Block a user