mirror of https://github.com/OCA/web.git
597 lines
19 KiB
JavaScript
597 lines
19 KiB
JavaScript
/* 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.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;
|
|
},
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
start: function () {
|
|
const attrs = this.arch.attrs;
|
|
this.current_window = {
|
|
start: new moment(),
|
|
end: new moment().add(24, "hours"),
|
|
};
|
|
|
|
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();
|
|
$(window).trigger("resize");
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Set the timeline window to today (day).
|
|
*
|
|
* @private
|
|
*/
|
|
_onTodayClicked: function () {
|
|
this.current_window = {
|
|
start: new moment(),
|
|
end: new moment().add(24, "hours"),
|
|
};
|
|
|
|
if (this.timeline) {
|
|
this.timeline.setWindow(this.current_window);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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(
|
|
24 * moment(this.current_window.start).daysInMonth()
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Scale the timeline window to a year.
|
|
*
|
|
* @private
|
|
*/
|
|
_onScaleYearClicked: function () {
|
|
this._scaleCurrentWindow(
|
|
24 * (moment(this.current_window.start).isLeapYear() ? 366 : 365)
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Scales the timeline window based on the current window.
|
|
*
|
|
* @param {Integer} factor The timespan (in hours) the window must be scaled to.
|
|
* @private
|
|
*/
|
|
_scaleCurrentWindow: function (factor) {
|
|
if (this.timeline) {
|
|
this.current_window = this.timeline.getWindow();
|
|
this.current_window.end = moment(this.current_window.start).add(
|
|
factor,
|
|
"hours"
|
|
);
|
|
this.timeline.setWindow(this.current_window);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 = {
|
|
// Add new items by double tapping
|
|
add: this.modelClass.data.rights.create,
|
|
// Drag items horizontally
|
|
updateTime: this.modelClass.data.rights.write,
|
|
// Drag items from one group to another
|
|
updateGroup: this.modelClass.data.rights.write,
|
|
// Delete an item by tapping the delete button top right
|
|
remove: this.modelClass.data.rights.unlink,
|
|
};
|
|
$.extend(this.options, {
|
|
onAdd: this.on_add,
|
|
onMove: this.on_move,
|
|
onUpdate: this.on_update,
|
|
onRemove: this.on_remove,
|
|
});
|
|
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),
|
|
{},
|
|
{xss: {disabled: true}}
|
|
);
|
|
this.timeline.setOptions(this.options);
|
|
if (this.mode && this["on_scale_" + this.mode + "_clicked"]) {
|
|
this["on_scale_" + this.mode + "_clicked"]();
|
|
}
|
|
this.timeline.on("click", this.on_group_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.on_data_loaded(this.modelClass.data.data, group_bys);
|
|
this.$centerContainer = $(this.timeline.dom.centerContainer);
|
|
this.canvas = new TimelineCanvas(this);
|
|
this.canvas.appendTo(this.$centerContainer);
|
|
this.timeline.on("changed", () => {
|
|
this.draw_canvas();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 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 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));
|
|
}
|
|
}
|
|
const groups = this.split_groups(events, group_bys);
|
|
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: 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 group_name = evt[_.first(group_bys)];
|
|
if (group_name) {
|
|
if (group_name instanceof Array) {
|
|
const group = _.find(
|
|
groups,
|
|
(existing_group) => existing_group.id === group_name[0]
|
|
);
|
|
if (_.isUndefined(group)) {
|
|
groups.push({
|
|
id: group_name[0],
|
|
content: group_name[1],
|
|
order: seq,
|
|
});
|
|
seq += 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 = _.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};`,
|
|
};
|
|
// Check if the event is instantaneous,
|
|
// if so, display it with a point on the timeline (no 'end')
|
|
if (date_stop && !moment(date_start).isSame(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 on a group header.
|
|
*
|
|
* @param {ClickEvent} e
|
|
* @private
|
|
*/
|
|
on_group_click: function (e) {
|
|
if (e.what === "group-label" && e.group !== -1) {
|
|
this._trigger(
|
|
e,
|
|
() => {
|
|
// Do nothing
|
|
},
|
|
"onGroupClick"
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 rights, and 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,
|
|
rights: this.modelClass.data.rights,
|
|
renderer: this,
|
|
});
|
|
},
|
|
});
|
|
|
|
return TimelineRenderer;
|
|
});
|