|
|
|
//-- copyright
|
|
|
|
// OpenProject is a project management system.
|
|
|
|
// Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
|
|
|
|
//
|
|
|
|
// This program is free software; you can redistribute it and/or
|
|
|
|
// modify it under the terms of the GNU General Public License version 3.
|
|
|
|
//
|
|
|
|
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
|
|
|
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
|
|
|
// Copyright (C) 2010-2013 the ChiliProject Team
|
|
|
|
//
|
|
|
|
// This program is free software; you can redistribute it and/or
|
|
|
|
// modify it under the terms of the GNU General Public License
|
|
|
|
// as published by the Free Software Foundation; either version 2
|
|
|
|
// of the License, or (at your option) any later version.
|
|
|
|
//
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
// GNU General Public License for more details.
|
|
|
|
//
|
|
|
|
// You should have received a copy of the GNU General Public License
|
|
|
|
// along with this program; if not, write to the Free Software
|
|
|
|
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
|
|
//
|
|
|
|
// See doc/COPYRIGHT.rdoc for more details.
|
|
|
|
//++
|
|
|
|
|
|
|
|
// ╭───────────────────────────────────────────────────────────────╮
|
|
|
|
// │ _____ _ _ _ │
|
|
|
|
// │ |_ _(_)_ __ ___ ___| (_)_ __ ___ ___ │
|
|
|
|
// │ | | | | '_ ` _ \ / _ \ | | '_ \ / _ \/ __| │
|
|
|
|
// │ | | | | | | | | | __/ | | | | | __/\__ \ │
|
|
|
|
// │ |_| |_|_| |_| |_|\___|_|_|_| |_|\___||___/ │
|
|
|
|
// ├───────────────────────────────────────────────────────────────┤
|
|
|
|
// │ Javascript library that fetches and plots timelines for the │
|
|
|
|
// │ OpenProject timelines module. │
|
|
|
|
// ╰───────────────────────────────────────────────────────────────╯
|
|
|
|
|
|
|
|
// This file adds some svg-helper methods
|
|
|
|
// to make svg creation easier
|
|
|
|
//= require timelines/SvgHelper
|
|
|
|
|
|
|
|
/*
|
|
|
|
* These files handle loading of timelines data.
|
|
|
|
* The TimelineLoader finds all dependencies and issues
|
|
|
|
* REST-server requests to grab the necessary data.
|
|
|
|
* The filterQueryStringBuilder creates our request parameters
|
|
|
|
* adding filter criteria to it.
|
|
|
|
*/
|
|
|
|
//= require timelines/FilterQueryStringBuilder
|
|
|
|
//= require timelines/TimelineLoader
|
|
|
|
|
|
|
|
// as our planning elements and projects are painted as a tree
|
|
|
|
// we need some representation of said tree and an easy method
|
|
|
|
// to iterate it. This class takes care of it!
|
|
|
|
//= require timelines/TreeNode
|
|
|
|
|
|
|
|
//= require timelines/constants
|
|
|
|
|
|
|
|
// renders the table and graph-background
|
|
|
|
//= require timelines/ui
|
|
|
|
|
|
|
|
// A model for the typical OpenProject Project.
|
|
|
|
//= require timelines/model/Project
|
|
|
|
|
|
|
|
// PlanningElements are what we paint as svgs in the end.
|
|
|
|
// PlanningElement is the old name for Work Package
|
|
|
|
//= require timelines/model/PlanningElement
|
|
|
|
|
|
|
|
// Historical Planning elements represent old states
|
|
|
|
// of planning elements for comparisons.
|
|
|
|
// These are painted as svgs too.
|
|
|
|
//= require timelines/model/HistoricalPlanningElement
|
|
|
|
|
|
|
|
// reportings are loaded first and they determine our
|
|
|
|
// project scope. Only projects reporting to the project
|
|
|
|
// we are currently looking at will be shown in the timeline.
|
|
|
|
//= require timelines/model/Reporting
|
|
|
|
|
|
|
|
// associations are nondirection relationships between projects.
|
|
|
|
// they are mainly used for the second level grouping which is
|
|
|
|
// calculated in the function secondLevelGroupingAdjustments.
|
|
|
|
//= require timelines/model/ProjectAssociation
|
|
|
|
|
|
|
|
// remaining simple models for project type, color, status
|
|
|
|
// planning element type and user.
|
|
|
|
//= require timelines/model/ProjectType
|
|
|
|
//= require timelines/model/Color
|
|
|
|
//= require timelines/model/CustomFields
|
|
|
|
//= require timelines/model/Status
|
|
|
|
//= require timelines/model/PlanningElementType
|
|
|
|
//= require timelines/model/User
|
|
|
|
|
|
|
|
/* startup
|
|
|
|
* -> setupUI -> loader -> load & create model objects
|
|
|
|
* -> link model objects
|
|
|
|
* -> getLeftHandTree
|
|
|
|
* -> completeUI -> zoom -> rebuildAll -> rebuildTree -> filter nodes
|
|
|
|
* -> rebuldGraph -> render models in svg
|
|
|
|
*/
|
|
|
|
|
|
|
|
// stricter than default
|
|
|
|
/*jshint undef:true,
|
|
|
|
eqeqeq:true,
|
|
|
|
forin:true,
|
|
|
|
immed:true,
|
|
|
|
latedef:true,
|
|
|
|
trailing: true
|
|
|
|
*/
|
|
|
|
|
|
|
|
// looser than default
|
|
|
|
/*jshint eqnull:true */
|
|
|
|
|
|
|
|
// environment and other global vars
|
|
|
|
/*jshint browser:true, devel:true*/
|
|
|
|
/*global jQuery:false, Timeline:true, modalHelperInstance: true, I18n: true*/
|
|
|
|
|
|
|
|
if (typeof Timeline === "undefined") {
|
|
|
|
Timeline = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
//startup
|
|
|
|
jQuery.extend(Timeline, {
|
|
|
|
instances: [],
|
|
|
|
get: function(n) {
|
|
|
|
if (typeof n !== "number") {
|
|
|
|
n = 0;
|
|
|
|
}
|
|
|
|
return this.instances[n];
|
|
|
|
},
|
|
|
|
isInstance: function(n) {
|
|
|
|
return (n === undefined) ?
|
|
|
|
Timeline.instances.indexOf(this) :
|
|
|
|
this === Timeline.get(n);
|
|
|
|
},
|
|
|
|
isGrouping: function() {
|
|
|
|
if ((this.options.grouping_one_enabled === 'yes' &&
|
|
|
|
this.options.grouping_one_selection !== undefined) ||
|
|
|
|
(this.options.grouping_two_enabled === 'yes' &&
|
|
|
|
this.options.grouping_two_selection !== undefined)) {
|
|
|
|
return true;
|
|
|
|
} else {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
isComparing: function() {
|
|
|
|
return ((this.options.comparison !== undefined) &&
|
|
|
|
(this.options.comparison !== 'none'));
|
|
|
|
},
|
|
|
|
comparisonCurrentTime: function() {
|
|
|
|
var value;
|
|
|
|
if (!this.isComparing()) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
if (this.options.comparison === 'historical') {
|
|
|
|
value = this.options.compare_to_historical_two;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
// default is no (undefined) current time, which corresponds to today.
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
return +Date.parse(value) / 1000;
|
|
|
|
},
|
|
|
|
calculateTimeFilter: function () {
|
|
|
|
if (!this.frameSet) {
|
|
|
|
if (this.options.planning_element_time === "absolute") {
|
|
|
|
this.frameStart = Date.parse(this.options.planning_element_time_absolute_one);
|
|
|
|
this.frameEnd = Date.parse(this.options.planning_element_time_absolute_two);
|
|
|
|
} else if (this.options.planning_element_time === "relative") {
|
|
|
|
var startR = parseInt(this.options.planning_element_time_relative_one, 10);
|
|
|
|
var endR = parseInt(this.options.planning_element_time_relative_two, 10);
|
|
|
|
if (!isNaN(startR)) {
|
|
|
|
this.frameStart = Date.now();
|
|
|
|
|
|
|
|
switch (this.options.planning_element_time_relative_one_unit[0]) {
|
|
|
|
case "0":
|
|
|
|
this.frameStart.add(-1 * startR).days();
|
|
|
|
break;
|
|
|
|
case "1":
|
|
|
|
this.frameStart.add(-1 * startR).weeks();
|
|
|
|
break;
|
|
|
|
case "2":
|
|
|
|
this.frameStart.add(-1 * startR).months();
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isNaN(endR)) {
|
|
|
|
this.frameEnd = Date.now();
|
|
|
|
|
|
|
|
switch (this.options.planning_element_time_relative_two_unit[0]) {
|
|
|
|
case "0":
|
|
|
|
this.frameEnd.add(endR).days();
|
|
|
|
break;
|
|
|
|
case "1":
|
|
|
|
this.frameEnd.add(endR).weeks();
|
|
|
|
break;
|
|
|
|
case "2":
|
|
|
|
this.frameEnd.add(endR).months();
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.frameSet = true;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
inTimeFilter: function (start, end) {
|
|
|
|
this.calculateTimeFilter();
|
|
|
|
|
|
|
|
if (!start && !end) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!start) {
|
|
|
|
start = end;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!end) {
|
|
|
|
end = start;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.frameStart) {
|
|
|
|
if (start < this.frameStart && end < this.frameStart) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.frameEnd) {
|
|
|
|
if (start > this.frameEnd && end > this.frameEnd) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
},
|
|
|
|
verticalPlanningElementIds: function() {
|
|
|
|
|
|
|
|
return this.options.vertical_planning_elements ?
|
|
|
|
jQuery.map(
|
|
|
|
this.options.vertical_planning_elements.split(/\,/),
|
|
|
|
function(a) {
|
|
|
|
try {
|
|
|
|
return parseInt(a.match(/\s*\*?(\d*)\s*/)[1], 10);
|
|
|
|
} catch (e) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
) : [];
|
|
|
|
},
|
|
|
|
comparisonTarget: function() {
|
|
|
|
var result, value, unit;
|
|
|
|
if (!this.isComparing()) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
switch (this.options.comparison) {
|
|
|
|
case 'relative':
|
|
|
|
result = new Date();
|
|
|
|
value = Timeline.pnum(this.options.compare_to_relative);
|
|
|
|
unit = Timeline.pnum(this.options.compare_to_relative_unit[0]);
|
|
|
|
switch (unit) {
|
|
|
|
case 0:
|
|
|
|
return Math.floor(result.add(-value).days() / 1000);
|
|
|
|
case 1:
|
|
|
|
return Math.floor(result.add(-value).weeks() / 1000);
|
|
|
|
case 2:
|
|
|
|
return Math.floor(result.add(-value).months() / 1000);
|
|
|
|
default:
|
|
|
|
return this.die(this.i18n('timelines.errors.report_comparison'));
|
|
|
|
}
|
|
|
|
break; // to please jslint
|
|
|
|
case 'absolute':
|
|
|
|
value = this.options.compare_to_absolute;
|
|
|
|
break;
|
|
|
|
case 'historical':
|
|
|
|
value = this.options.compare_to_historical_one;
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
return this.die(this.i18n('timelines.errors.report_comparison'));
|
|
|
|
}
|
|
|
|
return +Date.parse(value)/1000;
|
|
|
|
},
|
|
|
|
create: function() {
|
|
|
|
var timeline = Object.create(Timeline);
|
|
|
|
|
|
|
|
// some private fields.
|
|
|
|
timeline.listeners = [];
|
|
|
|
timeline.data = {};
|
|
|
|
|
|
|
|
Timeline.instances.push(timeline);
|
|
|
|
return timeline;
|
|
|
|
},
|
|
|
|
|
|
|
|
startup: function(options) {
|
|
|
|
var timeline = this, timelineLoader;
|
|
|
|
|
|
|
|
if(this === Timeline) {
|
|
|
|
timeline = Timeline.create();
|
|
|
|
return timeline.startup(options);
|
|
|
|
}
|
|
|
|
|
|
|
|
// configuration
|
|
|
|
|
|
|
|
if (!options) {
|
|
|
|
throw new Error('No configuration options given');
|
|
|
|
}
|
|
|
|
options = jQuery.extend({}, this.defaults, options);
|
|
|
|
this.options = options;
|
|
|
|
|
|
|
|
// we're hiding the root if there is a grouping.
|
|
|
|
this.options.hide_tree_root = this.isGrouping();
|
|
|
|
|
|
|
|
if (this.options.username) {
|
|
|
|
this.ajax_defaults.username = this.options.username;
|
|
|
|
}
|
|
|
|
if (this.options.password) {
|
|
|
|
this.ajax_defaults.password = this.options.password;
|
|
|
|
}
|
|
|
|
if (this.options.api_key) {
|
|
|
|
this.ajax_defaults.headers = {
|
|
|
|
'X-ChiliProject-API-Key': this.options.api_key,
|
|
|
|
'X-OpenProject-API-Key': this.options.api_key,
|
|
|
|
'X-Redmine-API-Key': this.options.api_key
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// setup UI.
|
|
|
|
|
|
|
|
this.uiRoot = this.options.ui_root;
|
|
|
|
this.setupUI();
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
// prerequisites (3rd party libs)
|
|
|
|
this.checkPrerequisites();
|
|
|
|
this.modalHelper = modalHelperInstance;
|
|
|
|
this.modalHelper.setupTimeline(
|
|
|
|
this,
|
|
|
|
{
|
|
|
|
api_prefix : this.options.api_prefix,
|
|
|
|
url_prefix : this.options.url_prefix,
|
|
|
|
project_prefix : this.options.project_prefix
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
jQuery(this.modalHelper).on("closed", function () {
|
|
|
|
timeline.reload();
|
|
|
|
});
|
|
|
|
|
|
|
|
timelineLoader = this.provideTimelineLoader();
|
|
|
|
|
|
|
|
jQuery(timelineLoader).on('complete', jQuery.proxy(function(e, data) {
|
|
|
|
jQuery.extend(this, data);
|
|
|
|
|
|
|
|
jQuery(this).trigger('dataLoaded');
|
|
|
|
this.defer(jQuery.proxy(this, 'onLoadComplete'),
|
|
|
|
this.options.artificial_load_delay);
|
|
|
|
}, this));
|
|
|
|
|
|
|
|
this.safetyHook = window.setTimeout(function() {
|
|
|
|
timeline.die(timeline.i18n('timelines.errors.report_timeout'));
|
|
|
|
}, Timeline.LOAD_ERROR_TIMEOUT);
|
|
|
|
|
|
|
|
timelineLoader.load();
|
|
|
|
|
|
|
|
return this;
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
this.die(e);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
checkPrerequisites: function() {
|
|
|
|
if (jQuery === undefined) {
|
|
|
|
throw new Error('jQuery seems to be missing (jQuery is undefined)');
|
|
|
|
} else if (jQuery().slider === undefined) {
|
|
|
|
throw new Error('jQuery UI seems to be missing (jQuery().slider is undefined)');
|
|
|
|
} else if ((1).month === undefined) {
|
|
|
|
throw new Error('date.js seems to be missing ((1).month is undefined)');
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
},
|
|
|
|
reload: function() {
|
|
|
|
delete this.lefthandTree;
|
|
|
|
|
|
|
|
var timelineLoader = this.provideTimelineLoader();
|
|
|
|
|
|
|
|
jQuery(timelineLoader).on('complete', jQuery.proxy(function (e, data) {
|
|
|
|
|
|
|
|
jQuery.extend(this, data);
|
|
|
|
jQuery(this).trigger('dataReLoaded');
|
|
|
|
|
|
|
|
if (this.isGrouping() && this.options.grouping_two_enabled) {
|
|
|
|
this.secondLevelGroupingAdjustments();
|
|
|
|
}
|
|
|
|
|
|
|
|
this.adjustForPlanningElements();
|
|
|
|
|
|
|
|
this.rebuildAll();
|
|
|
|
|
|
|
|
}, this));
|
|
|
|
|
|
|
|
timelineLoader.load();
|
|
|
|
},
|
|
|
|
provideTimelineLoader: function() {
|
|
|
|
return new Timeline.TimelineLoader(
|
|
|
|
this,
|
|
|
|
{
|
|
|
|
api_prefix : this.options.api_prefix,
|
|
|
|
url_prefix : this.options.url_prefix,
|
|
|
|
project_prefix : this.options.project_prefix,
|
|
|
|
planning_element_prefix : this.options.planning_element_prefix,
|
|
|
|
timeline_id : this.options.timeline_id,
|
|
|
|
project_id : this.options.project_id,
|
|
|
|
project_types : this.options.project_types,
|
|
|
|
project_statuses : this.options.project_status,
|
|
|
|
project_responsibles : this.options.project_responsibles,
|
|
|
|
project_parents : this.options.parents,
|
|
|
|
planning_element_types : this.options.planning_element_types,
|
|
|
|
planning_element_responsibles : this.options.planning_element_responsibles,
|
|
|
|
planning_element_assignee : this.options.planning_element_assignee,
|
|
|
|
custom_fields : this.options.custom_fields,
|
|
|
|
planning_element_status : this.options.planning_element_status,
|
|
|
|
grouping_one : (this.options.grouping_one_enabled ? this.options.grouping_one_selection : undefined),
|
|
|
|
grouping_two : (this.options.grouping_two_enabled ? this.options.grouping_two_selection : undefined),
|
|
|
|
ajax_defaults : this.ajax_defaults,
|
|
|
|
current_time : this.comparisonCurrentTime(),
|
|
|
|
target_time : this.comparisonTarget(),
|
|
|
|
include_planning_elements : this.verticalPlanningElementIds()
|
|
|
|
}
|
|
|
|
);
|
|
|
|
},
|
|
|
|
defer: function(action, delay) {
|
|
|
|
var timeline = this;
|
|
|
|
var result;
|
|
|
|
if (delay === undefined) {
|
|
|
|
delay = 0;
|
|
|
|
}
|
|
|
|
result = window.setTimeout(function() {
|
|
|
|
try {
|
|
|
|
action.call();
|
|
|
|
} catch(e) {
|
|
|
|
timeline.die(e);
|
|
|
|
}
|
|
|
|
}, 0);
|
|
|
|
return result;
|
|
|
|
},
|
|
|
|
die: function(error, classes) {
|
|
|
|
var message = (typeof error === 'string') ? error :
|
|
|
|
this.i18n('timelines.errors.report_epicfail'); // + '<br>' + error.message;
|
|
|
|
classes = classes || 'flash error';
|
|
|
|
|
|
|
|
this.warn(message, classes);
|
|
|
|
|
|
|
|
// assume this won't happen anymore.
|
|
|
|
this.onLoadComplete = function() {};
|
|
|
|
|
|
|
|
if (console && console.log) {
|
|
|
|
console.log(error.stack);
|
|
|
|
}
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
},
|
|
|
|
warn: function(message, classes) {
|
|
|
|
var root = this.getUiRoot();
|
|
|
|
|
|
|
|
window.setTimeout(function() {
|
|
|
|
|
|
|
|
// generate and display the error message.
|
|
|
|
var warning = jQuery('<div class="' + classes + '">' + message + '</div>');
|
|
|
|
root.empty().append(warning);
|
|
|
|
|
|
|
|
}, Timeline.DISPLAY_ERROR_DELAY);
|
|
|
|
},
|
|
|
|
onLoadComplete: function() {
|
|
|
|
// everything here should be wrapped in try/catch, to never
|
|
|
|
var tree;
|
|
|
|
try {
|
|
|
|
window.clearTimeout(this.safetyHook);
|
|
|
|
|
|
|
|
if (this.isGrouping() && this.options.grouping_two_enabled) {
|
|
|
|
this.secondLevelGroupingAdjustments();
|
|
|
|
}
|
|
|
|
|
|
|
|
tree = this.getLefthandTree();
|
|
|
|
if (tree.containsPlanningElements() || tree.containsProjects()) {
|
|
|
|
this.adjustForPlanningElements();
|
|
|
|
this.completeUI();
|
|
|
|
} else {
|
|
|
|
this.warn(this.i18n('label_no_data'), 'warning');
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
this.die(e);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
/* This function calculates the second level grouping adjustments.
|
|
|
|
* For every base project it finds all associates with the given project type.
|
|
|
|
* It removes every such project from the trees root and adds it underneath the base project.
|
|
|
|
*/
|
|
|
|
secondLevelGroupingAdjustments: function () {
|
|
|
|
var grouping = jQuery.map(this.options.grouping_two_selection || [], Timeline.pnum);
|
|
|
|
var root = this.getProject();
|
|
|
|
var associations = Timeline.ProjectAssociation.all(this);
|
|
|
|
var listToRemove = [];
|
|
|
|
|
|
|
|
// for all projects on the first level
|
|
|
|
jQuery.each(root.getReporters(), function (i, reporter) {
|
|
|
|
|
|
|
|
// find all projects that are associated
|
|
|
|
jQuery.each(associations, function (j, association) {
|
|
|
|
|
|
|
|
// check if the reporter is involved and hasn't already been included by a second level grouping adjustment
|
|
|
|
if (!reporter.hasSecondLevelGroupingAdjustment && association.involves(reporter)) {
|
|
|
|
var other = association.getOther(reporter);
|
|
|
|
if (typeof other.getProjectType === "function") {
|
|
|
|
var projectType = other.getProjectType();
|
|
|
|
var projectTypeId = projectType !== null ? projectType.id : -1;
|
|
|
|
|
|
|
|
//check if the type is selected as 2nd level grouping
|
|
|
|
if (grouping.indexOf(projectTypeId) !== -1) {
|
|
|
|
// add the other project as a simulated reporter to the current one.
|
|
|
|
reporter.addReporter(other);
|
|
|
|
other.hasSecondLevelGroupingAdjustment = true;
|
|
|
|
// remove the project from the root level of the report.
|
|
|
|
listToRemove.push(other);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// remove all children of root that we couldn't remove while still iterating.
|
|
|
|
jQuery.each(listToRemove, function(i, reporter) {
|
|
|
|
root.removeReporter(reporter);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
|
|
// │ Defaults and random accessors │
|
|
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
jQuery.extend(Timeline, {
|
|
|
|
// defines how many levels are expanded when a tree is created, zero
|
|
|
|
// corresponds to the root being collapsed.
|
|
|
|
firstDateSeen: null,
|
|
|
|
lastDateSeen: null,
|
|
|
|
|
|
|
|
getBeginning: function() {
|
|
|
|
return (Date.parse(this.options.timeframe_start) ||
|
|
|
|
(this.firstDateSeen && this.firstDateSeen.clone() ||
|
|
|
|
new Date()).last().monday());
|
|
|
|
},
|
|
|
|
getEnd: function() {
|
|
|
|
return (Date.parse(this.options.timeframe_end) ||
|
|
|
|
(this.lastDateSeen && this.lastDateSeen.clone() ||
|
|
|
|
new Date()).addWeeks(1).next().sunday());
|
|
|
|
},
|
|
|
|
getDaysBetween: function(a, b) {
|
|
|
|
// some meat around date calculations that will be floored out again
|
|
|
|
// later. this hopefully takes care of floating point imprecisions
|
|
|
|
// and possible leap seconds, as we're only interested in days.
|
|
|
|
var da = a - 1000 * 60 * 60 * 4;
|
|
|
|
var db = b - 1000 * 60 * 60 * (-4);
|
|
|
|
return Math.floor((db - da) / (1000 * 60 * 60 * 24));
|
|
|
|
},
|
|
|
|
includeDate: function(date) {
|
|
|
|
if (date) {
|
|
|
|
if (this.firstDateSeen == null ||
|
|
|
|
date.compareTo(this.firstDateSeen) < 0) {
|
|
|
|
this.firstDateSeen = date;
|
|
|
|
} else if (this.lastDateSeen == null ||
|
|
|
|
date.compareTo(this.lastDateSeen) > 0) {
|
|
|
|
this.lastDateSeen = date;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
adjustForPlanningElements: function() {
|
|
|
|
var timeline = this;
|
|
|
|
var tree = this.getLefthandTree();
|
|
|
|
|
|
|
|
// nullify potential previous dates seen. this is relevant when
|
|
|
|
// adjusting after the addition of a planning element via modal.
|
|
|
|
|
|
|
|
timeline.firstDateSeen = null;
|
|
|
|
timeline.lastDateSeen = null;
|
|
|
|
|
|
|
|
tree.iterateWithChildren(function(node) {
|
|
|
|
var data = node.getData();
|
|
|
|
if (data.is(Timeline.PlanningElement)) {
|
|
|
|
timeline.includeDate(data.start());
|
|
|
|
timeline.includeDate(data.end());
|
|
|
|
}
|
|
|
|
}, {
|
|
|
|
traverseCollapsed: true
|
|
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
getCustomFieldColumns: function() {
|
|
|
|
return this.options.columns.filter(function(column) {
|
|
|
|
return column.substr(0, 3) === 'cf_';
|
|
|
|
});
|
|
|
|
},
|
|
|
|
getCustomFields: function() {
|
|
|
|
var customFields = [];
|
|
|
|
|
|
|
|
jQuery.each(this.custom_fields, function(key, customField) {
|
|
|
|
customFields.push(customField);
|
|
|
|
});
|
|
|
|
|
|
|
|
return customFields;
|
|
|
|
},
|
|
|
|
getValidCustomFieldIds: function() {
|
|
|
|
return this.getCustomFields().map(function(cf) {
|
|
|
|
return cf.id;
|
|
|
|
});
|
|
|
|
},
|
|
|
|
getIdOfCustomFieldColumn: function(cfColumn) {
|
|
|
|
return parseInt(cfColumn.substr(3, 10), 10);
|
|
|
|
},
|
|
|
|
getInvalidCustomFieldColumns: function() {
|
|
|
|
var validCustomFieldIds = this.getValidCustomFieldIds();
|
|
|
|
var timeline = this;
|
|
|
|
|
|
|
|
return this.getCustomFieldColumns().filter(function(cfColumn) {
|
|
|
|
return validCustomFieldIds.indexOf(timeline.getIdOfCustomFieldColumn(cfColumn)) === -1;
|
|
|
|
});
|
|
|
|
},
|
|
|
|
removeColumnByName: function(columnName) {
|
|
|
|
this.options.columns.splice(this.options.columns.indexOf(columnName), 1);
|
|
|
|
},
|
|
|
|
clearUpCustomFieldColumns: function() {
|
|
|
|
var timeline = this;
|
|
|
|
|
|
|
|
jQuery.each(this.getInvalidCustomFieldColumns(), function(i, cfColumn) {
|
|
|
|
timeline.removeColumnByName(cfColumn);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
getReportings: function() {
|
|
|
|
return Timeline.Reporting.all(this);
|
|
|
|
},
|
|
|
|
getReporting: function(id) {
|
|
|
|
return this.reportings[id];
|
|
|
|
},
|
|
|
|
getProjects: function() {
|
|
|
|
return Timeline.Project.all(this);
|
|
|
|
},
|
|
|
|
getProject: function(id) {
|
|
|
|
if (id === undefined) {
|
|
|
|
return this.project;
|
|
|
|
}
|
|
|
|
else return this.projects[id];
|
|
|
|
},
|
|
|
|
getGroupForProject: function(p) {
|
|
|
|
var i, j = 0, projects, key, group;
|
|
|
|
var groups = this.getFirstLevelGroups();
|
|
|
|
for (j = 0; j < groups.length; j += 1) {
|
|
|
|
projects = groups[j].projects;
|
|
|
|
group = this.getProject(groups[j].id);
|
|
|
|
|
|
|
|
for (i = 0; i < projects.length; i++) {
|
|
|
|
if (p.id === projects[i].id) {
|
|
|
|
return {
|
|
|
|
'id': group.id,
|
|
|
|
'p': group,
|
|
|
|
'number': j + 1,
|
|
|
|
'name': group.name
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
'number': 0,
|
|
|
|
'id': 0,
|
|
|
|
'name': this.i18n('timelines.filter.grouping_other')
|
|
|
|
};
|
|
|
|
},
|
|
|
|
|
|
|
|
firstLevelGroups: undefined,
|
|
|
|
getFirstLevelGroups: function() {
|
|
|
|
if (this.firstLevelGroups !== undefined) {
|
|
|
|
return this.firstLevelGroups;
|
|
|
|
}
|
|
|
|
var i, selection = this.options.grouping_one_selection;
|
|
|
|
var p, groups = [], children;
|
|
|
|
if (this.isGrouping() && selection) {
|
|
|
|
for ( i = 0; i < selection.length; i++ ) {
|
|
|
|
p = this.getProject(selection[i]);
|
|
|
|
if (p === undefined) {
|
|
|
|
// projects may have subprojects that the current user knows
|
|
|
|
// about, but which cannot be/ were not fetched in advance due
|
|
|
|
// to lack of rights.
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
children = this.getSubprojectsOf([selection[i]]);
|
|
|
|
if (children.length !== 0) {
|
|
|
|
groups.push({
|
|
|
|
projects: children,
|
|
|
|
id: selection[i]
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.firstLevelGroups = groups;
|
|
|
|
return groups;
|
|
|
|
},
|
|
|
|
getNumberOfGroups: function() {
|
|
|
|
var result = this.options.hide_other_group? 0: 1;
|
|
|
|
var groups = this.getFirstLevelGroups();
|
|
|
|
return result + groups.length;
|
|
|
|
},
|
|
|
|
getSubprojectsOf: function(parents) {
|
|
|
|
var projects = this.getProjects();
|
|
|
|
var result = [];
|
|
|
|
var timeline = this;
|
|
|
|
|
|
|
|
// if parents is not an array, turn it into one with length 1, so
|
|
|
|
// the following each does not fail.
|
|
|
|
if (!(parents instanceof Array)) {
|
|
|
|
parents = [parents];
|
|
|
|
}
|
|
|
|
|
|
|
|
var ancestorIsIn = function(project, ancestors) {
|
|
|
|
|
|
|
|
var parent = project.getParent();
|
|
|
|
var r = false;
|
|
|
|
if (parent !== null) {
|
|
|
|
jQuery.each(ancestors, function(i, p) {
|
|
|
|
|
|
|
|
// make sure this is a number. when coming from the options
|
|
|
|
// array, it might actually be an array of strings.
|
|
|
|
if (typeof p === 'string') {
|
|
|
|
p = timeline.pnum(p);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (parent && p === parent.id) {
|
|
|
|
r = true;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// check rest of project tree. this might break when a project
|
|
|
|
// in between is not visible to the current user.
|
|
|
|
if (parent) {
|
|
|
|
r = r || ancestorIsIn(parent, ancestors);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return r;
|
|
|
|
};
|
|
|
|
|
|
|
|
jQuery.each(projects, function(i, e) {
|
|
|
|
if (ancestorIsIn(e, parents)) {
|
|
|
|
result.push(e);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return result;
|
|
|
|
},
|
|
|
|
getProjectTypes: function() {
|
|
|
|
return Timeline.ProjectType.all(this);
|
|
|
|
},
|
|
|
|
getProjectType: function(id) {
|
|
|
|
return this.project_types[id];
|
|
|
|
},
|
|
|
|
getPlanningElementTypes: function() {
|
|
|
|
return Timeline.PlanningElementType.all(this);
|
|
|
|
},
|
|
|
|
getPlanningElementType: function(id) {
|
|
|
|
return this.planning_element_types[id];
|
|
|
|
},
|
|
|
|
getPlanningElements: function() {
|
|
|
|
return Timeline.PlanningElement.all(this);
|
|
|
|
},
|
|
|
|
getPlanningElement: function(id) {
|
|
|
|
return this.planning_elements[id];
|
|
|
|
},
|
|
|
|
getColors: function() {
|
|
|
|
return Timeline.Color.all(this);
|
|
|
|
},
|
|
|
|
getProjectAssociations: function() {
|
|
|
|
return Timeline.ProjectAssociation.all(this);
|
|
|
|
},
|
|
|
|
getLefthandTree: function() {
|
|
|
|
|
|
|
|
if (!this.lefthandTree) {
|
|
|
|
|
|
|
|
// as long as there are no stored filters or aggregates, we only use
|
|
|
|
// the projects as roots.
|
|
|
|
var project = this.getProject();
|
|
|
|
var tree = Object.create(Timeline.TreeNode);
|
|
|
|
var parent_stack = [];
|
|
|
|
|
|
|
|
tree.setData(project);
|
|
|
|
|
|
|
|
// there might not be any payload, due to insufficient rights and
|
|
|
|
// the fact that some user with more rights originally created the
|
|
|
|
// report.
|
|
|
|
if (!project) {
|
|
|
|
// FLAG raise some flag indicating that something is
|
|
|
|
// wrong/missing.
|
|
|
|
return tree;
|
|
|
|
}
|
|
|
|
|
|
|
|
var count = 1;
|
|
|
|
// for the given node, appends the given planning_elements as children,
|
|
|
|
// recursively. every node will have the planning_element as data.
|
|
|
|
var treeConstructor = function(node, elements) {
|
|
|
|
count += 1;
|
|
|
|
|
|
|
|
var MAXIMUMPROJECTCOUNT = 12000;
|
|
|
|
if (count > MAXIMUMPROJECTCOUNT) {
|
|
|
|
throw I18n.t('js.timelines.tooManyProjects', {count: MAXIMUMPROJECTCOUNT});
|
|
|
|
}
|
|
|
|
|
|
|
|
jQuery.each(elements, function(i, e) {
|
|
|
|
parent_stack.push(node.payload);
|
|
|
|
for (var j = 0; j < parent_stack.length; j++) {
|
|
|
|
if (parent_stack[j] === e) {
|
|
|
|
parent_stack.pop();
|
|
|
|
return; // no more recursion!
|
|
|
|
}
|
|
|
|
}
|
|
|
|
var newNode = Object.create(Timeline.TreeNode);
|
|
|
|
newNode.setData(e);
|
|
|
|
node.appendChild(newNode);
|
|
|
|
treeConstructor(newNode, newNode.getData().getSubElements());
|
|
|
|
parent_stack.pop();
|
|
|
|
});
|
|
|
|
return node;
|
|
|
|
};
|
|
|
|
|
|
|
|
this.lefthandTree = treeConstructor(tree, project.getSubElements());
|
|
|
|
|
|
|
|
this.lefthandTree.expandTo(this.options.initial_outline_expansion);
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.lefthandTree;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// This polyfill covers the main use case which is creating a new object
|
|
|
|
// for which the prototype has been chosen but doesn't take the second
|
|
|
|
// argument into account:
|
|
|
|
// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Object/create
|
|
|
|
if (!Object.create) {
|
|
|
|
Object.create = function(o) {
|
|
|
|
if (arguments.length > 1) {
|
|
|
|
throw new Error(
|
|
|
|
'Object.create implementation only accepts the first parameter.'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
function F() {}
|
|
|
|
F.prototype = o;
|
|
|
|
return new F();
|
|
|
|
};
|
|
|
|
}
|