合并企业版代码(未测试,先提交到测试分支)
This commit is contained in:
336
web_gantt/static/src/js/connector/connector.js
Normal file
336
web_gantt/static/src/js/connector/connector.js
Normal file
@@ -0,0 +1,336 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { deepMerge } from "./connector_utils";
|
||||
|
||||
const { Component, onWillUpdateProps } = owl;
|
||||
|
||||
class Connector extends Component {
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Life cycle hooks
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
this._refreshPropertiesFromProps(this.props);
|
||||
|
||||
onWillUpdateProps(this.onWillUpdateProps);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @override
|
||||
* @param nextProps
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onWillUpdateProps(nextProps) {
|
||||
this._refreshPropertiesFromProps(nextProps);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refreshes the connector properties from the props.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @private
|
||||
*/
|
||||
_refreshPropertiesFromProps(props) {
|
||||
const defaultStyleProps = {
|
||||
drawHead: true,
|
||||
outlineStroke: {
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
hoveredColor: 'rgba(255,255,255,0.9)',
|
||||
width: 2,
|
||||
},
|
||||
slackness: 0.5,
|
||||
stroke: {
|
||||
color: 'rgba(0,0,0,0.5)',
|
||||
hoveredColor: 'rgba(0,0,0,0.9)',
|
||||
width: 2,
|
||||
},
|
||||
};
|
||||
this.hoverEaseWidth = props.hoverEaseWidth ? props.hoverEaseWidth : 1;
|
||||
this.style = deepMerge(defaultStyleProps, props.style);
|
||||
const pathInfo = this._getPathInfo(props.source, props.target, this.style.slackness);
|
||||
this.path = `M ${pathInfo.singlePath.source.left} ${pathInfo.singlePath.source.top} \
|
||||
C ${pathInfo.singlePath.sourceControlPoint.left} ${pathInfo.singlePath.sourceControlPoint.top} \
|
||||
${pathInfo.singlePath.targetControlPoint.left} ${pathInfo.singlePath.targetControlPoint.top} \
|
||||
${pathInfo.singlePath.target.left} ${pathInfo.singlePath.target.top}`;
|
||||
this.removeButtonPosition = pathInfo.doublePath.startingPath.target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameters of both the single Bezier curve as well as is decomposition into two beziers curves
|
||||
* (which allows to get the middle position of the single Bezier curve) for the provided source, target and
|
||||
* slackness (0 being a straight line).
|
||||
*
|
||||
* @param {{ left: number, top: number }} source
|
||||
* @param {{ left: number, top: number }} target
|
||||
* @param {number} slackness [0, 1]
|
||||
* @returns {{
|
||||
* singlePath: {
|
||||
* source: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* sourceControlPoint: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* target: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* targetControlPoint: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* }
|
||||
* },
|
||||
* doublePath: {
|
||||
* endingPath: {
|
||||
* source: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* sourceControlPoint: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* target: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* targetControlPoint: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* }
|
||||
* },
|
||||
* startingPath: {
|
||||
* source: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* sourceControlPoint: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* target: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* },
|
||||
* targetControlPoint: {
|
||||
* top: number,
|
||||
* left: number
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }}
|
||||
* @private
|
||||
*/
|
||||
_getPathInfo(source, target, slackness) {
|
||||
const b = { left: 0, top: 0 };
|
||||
const c = { left: 0, top: 0 };
|
||||
// If the source is on the left of the target, we need to invert the control points.
|
||||
const directionFactor = source.left < target.left ? 1 : -1;
|
||||
// What follows can be seen as magic numbers. And those are indeed such numbers as they have been determined
|
||||
// by observing their shape while creating short and long connectors. These seems to allow keeping the same
|
||||
// kind of shape amongst short and long connectors.
|
||||
const xDelta = 100 + directionFactor * (target.left - source.left) * slackness / 10;
|
||||
const yDelta = Math.abs(source.top - target.top) < 16 && source.left > target.left ? 15 + 0.001 * (source.left - target.left) * slackness : 0;
|
||||
b.left = source.left + xDelta;
|
||||
b.top = source.top + yDelta;
|
||||
// Prevent having the air pin effect when in creation and having target on the left of the source
|
||||
if (!this.props.inCreation || directionFactor > 0) {
|
||||
c.left = target.left - xDelta;
|
||||
} else {
|
||||
c.left = target.left + xDelta;
|
||||
}
|
||||
c.top = target.top + yDelta;
|
||||
|
||||
const cuttingDistance = 0.5;
|
||||
const e = Connector._getLinearInterpolation(source, b, cuttingDistance);
|
||||
const f = Connector._getLinearInterpolation(b, c, cuttingDistance);
|
||||
const g = Connector._getLinearInterpolation(c, target, cuttingDistance);
|
||||
const h = Connector._getLinearInterpolation(e, f, cuttingDistance);
|
||||
const i = Connector._getLinearInterpolation(f, g, cuttingDistance);
|
||||
const j = Connector._getLinearInterpolation(h, i, cuttingDistance);
|
||||
|
||||
return {
|
||||
singlePath: {
|
||||
source: source,
|
||||
sourceControlPoint: b,
|
||||
target: target,
|
||||
targetControlPoint: c,
|
||||
},
|
||||
doublePath: {
|
||||
endingPath: {
|
||||
source: j,
|
||||
sourceControlPoint: i,
|
||||
target: target,
|
||||
targetControlPoint: g,
|
||||
},
|
||||
startingPath: {
|
||||
source: source,
|
||||
sourceControlPoint: e,
|
||||
target: j,
|
||||
targetControlPoint: h,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handler for connector_stroke_buttons remove click event.
|
||||
*
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onRemoveButtonClick(ev) {
|
||||
const payload = {
|
||||
data: deepMerge(this.props.data),
|
||||
id: this.props.id,
|
||||
};
|
||||
this.props.onRemoveButtonClick(payload);
|
||||
}
|
||||
/**
|
||||
* Handler for connector_stroke_buttons reschedule sooner click event.
|
||||
*
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onRescheduleSoonerClick(ev) {
|
||||
const payload = {
|
||||
data: deepMerge(this.props.data),
|
||||
id: this.props.id,
|
||||
};
|
||||
this.props.onRescheduleSoonerButtonClick(payload);
|
||||
}
|
||||
/**
|
||||
* Handler for connector_stroke_buttons reschedule later click event.
|
||||
*
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onRescheduleLaterClick(ev) {
|
||||
const payload = {
|
||||
data: deepMerge(this.props.data),
|
||||
id: this.props.id,
|
||||
};
|
||||
this.props.onRescheduleLaterButtonClick(payload);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const endProps = {
|
||||
shape: {
|
||||
left: Number,
|
||||
top: Number,
|
||||
},
|
||||
type: Object,
|
||||
};
|
||||
const strokeStyleProps = {
|
||||
optional: true,
|
||||
shape: {
|
||||
color: {
|
||||
optional: true,
|
||||
type: String,
|
||||
},
|
||||
hoveredColor: {
|
||||
optional: true,
|
||||
type: String,
|
||||
},
|
||||
width: {
|
||||
optional: true,
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
type: Object,
|
||||
};
|
||||
|
||||
Object.assign(Connector, {
|
||||
props: {
|
||||
canBeRemoved: {
|
||||
optional: true,
|
||||
type: Boolean
|
||||
},
|
||||
data: {
|
||||
optional: true,
|
||||
type: Object,
|
||||
},
|
||||
hoverEaseWidth: {
|
||||
optional: true,
|
||||
type: Number,
|
||||
},
|
||||
hovered: {
|
||||
optional: true,
|
||||
type: Boolean
|
||||
},
|
||||
inCreation: {
|
||||
optional: true,
|
||||
type: Boolean,
|
||||
},
|
||||
id: { type: String | Number },
|
||||
source: endProps,
|
||||
style: {
|
||||
optional: true,
|
||||
shape: {
|
||||
drawHead: {
|
||||
optional: true,
|
||||
type: Boolean,
|
||||
},
|
||||
outlineStroke: strokeStyleProps,
|
||||
slackness: {
|
||||
optional: true,
|
||||
type: Number,
|
||||
validate: slackness => (0 <= slackness && slackness <= 1),
|
||||
},
|
||||
stroke: strokeStyleProps,
|
||||
},
|
||||
type: Object,
|
||||
},
|
||||
target: endProps,
|
||||
onRemoveButtonClick: { type: Function, optional: true },
|
||||
onRescheduleSoonerButtonClick: { type: Function, optional: true },
|
||||
onRescheduleLaterButtonClick: { type: Function, optional: true },
|
||||
},
|
||||
defaultProps: {
|
||||
onRemoveButtonClick: () => {},
|
||||
onRescheduleSoonerButtonClick: () => {},
|
||||
onRescheduleLaterButtonClick: () => {},
|
||||
},
|
||||
template: 'connector',
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Private Static
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the linear interpolation for a point to be found somewhere between a startingPoint and a endingPoint.
|
||||
*
|
||||
* @param {{top: number, left: number}} startingPoint
|
||||
* @param {{top: number, left: number}} endingPoint
|
||||
* @param {number} interpolationPercentage [0, 1] the distance (from 0 startingPoint to 1 endingPoint)
|
||||
* the point has to be computed at.
|
||||
* @returns {{top: number, left: number}}
|
||||
* @private
|
||||
*/
|
||||
_getLinearInterpolation(startingPoint, endingPoint, interpolationPercentage) {
|
||||
if (interpolationPercentage < 0 || 1 > interpolationPercentage) {
|
||||
// Ensures interpolationPercentage is within expected boundaries.
|
||||
interpolationPercentage = Math.min(Math.max (0, interpolationPercentage), 1);
|
||||
}
|
||||
const remaining = 1 - interpolationPercentage;
|
||||
return {
|
||||
left: startingPoint.left * remaining + endingPoint.left * interpolationPercentage,
|
||||
top: startingPoint.top * remaining + endingPoint.top * interpolationPercentage
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default Connector;
|
||||
414
web_gantt/static/src/js/connector/connector_container.js
Normal file
414
web_gantt/static/src/js/connector/connector_container.js
Normal file
@@ -0,0 +1,414 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import Connector from "./connector";
|
||||
import { deepMerge } from "./connector_utils";
|
||||
import { LegacyComponent } from "@web/legacy/legacy_component";
|
||||
|
||||
const { onMounted, onWillUnmount, onWillUpdateProps } = owl;
|
||||
|
||||
class ConnectorContainer extends LegacyComponent {
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Life cycle hooks
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
this._createsParentListenerHandlers();
|
||||
/**
|
||||
* Keeps track of the mouse events related info.
|
||||
* @type {{ isParentDragging: boolean, hoveredConnector: HTMLElement }}
|
||||
*/
|
||||
this.mouseEventsInfo = { };
|
||||
// Connector component used in order to manage connector creation.
|
||||
this.newConnector = {
|
||||
id: "newConnectorId",
|
||||
canBeRemoved: false,
|
||||
};
|
||||
// Apply default styling (if any) to newConnector.
|
||||
if ('defaultStyle' in this.props) {
|
||||
this.newConnector = deepMerge(this.newConnector, { style: this.props.defaultStyle });
|
||||
}
|
||||
if ('newConnectorStyle' in this.props) {
|
||||
this.newConnector = deepMerge(this.newConnector, { style: this.props.newConnectorStyle });
|
||||
}
|
||||
this._refreshPropertiesFromProps(this.props);
|
||||
|
||||
onMounted(this.onMounted);
|
||||
onWillUnmount(this.onWillUnmount);
|
||||
onWillUpdateProps(this.onWillUpdateProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onMounted() {
|
||||
if (this.parentElement && this.parentElement !== this.el.parentElement) {
|
||||
this._removeParentListeners();
|
||||
}
|
||||
this.parentElement = this.el.parentElement;
|
||||
this._addParentListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onWillUnmount() {
|
||||
this._removeParentListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @override
|
||||
* @param nextProps
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onWillUpdateProps(nextProps) {
|
||||
this._refreshPropertiesFromProps(nextProps);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adds the ConnectorContainer's parent required EventListeners.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addParentListeners() {
|
||||
this.parentElement.addEventListener('mousedown', this._onParentMouseDownHandler);
|
||||
this.parentElement.addEventListener('mousemove', this._throttledOnParentMouseOverHandler);
|
||||
this.parentElement.addEventListener('mouseup', this._onParentMouseUpHandler);
|
||||
this.parentElement.addEventListener('mouseleave', this._onParentMouseUpHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the handlers used in _addParentListeners and _removeParentListeners calls.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_createsParentListenerHandlers() {
|
||||
this._throttledOnParentMouseOverHandler = _.throttle((ev) => this._onParentMouseOver(ev), 50);
|
||||
this._onParentMouseDownHandler = (ev) => this._onParentMouseDown(ev);
|
||||
this._onParentMouseUpHandler = (ev) => this._onParentMouseUp(ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the element offset against the connectorContainer's parent.
|
||||
*
|
||||
* @param {HTMLElement} element the element the offset position has to be calculated for.
|
||||
*/
|
||||
_getElementPosition(element) {
|
||||
// This will not work for css translated elements and this is acceptable at this time.
|
||||
// If needed in the future, consider using getBoundingClientRect() if getComputedStyle() returns a style
|
||||
// having a transform (and place this also in the for loop as we would need to do it if the element or any of
|
||||
// its parent is using css transform).
|
||||
let left = element.offsetLeft || 0;
|
||||
let top = element.offsetTop || 0;
|
||||
for (let el = element.offsetParent; el != null && el !== this.el.parentElement; el = el.offsetParent) {
|
||||
left += el.offsetLeft || 0;
|
||||
top += el.offsetTop || 0;
|
||||
}
|
||||
return {
|
||||
left: left,
|
||||
top: top,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the connector properties from the props.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @private
|
||||
*/
|
||||
_refreshPropertiesFromProps(props) {
|
||||
this.connectors = deepMerge(props.connectors);
|
||||
if (this.props.defaultStyle) {
|
||||
Object.keys(this.connectors)
|
||||
.forEach((key) => {
|
||||
this.connectors[key].style = deepMerge(this.props.defaultStyle, this.connectors[key].style);
|
||||
if (!('hoverEaseWidth' in this.connectors[key]) && 'hoverEaseWidth' in props) {
|
||||
this.connectors[key].hoverEaseWidth = props.hoverEaseWidth;
|
||||
}
|
||||
});
|
||||
}
|
||||
const isHoveredConnectorSet = 'hoveredConnector' in this.mouseEventsInfo;
|
||||
const isHoveredConnectorPartOfProps = isHoveredConnectorSet && this.mouseEventsInfo.hoveredConnector.dataset.id in this.connectors;
|
||||
if (isHoveredConnectorSet && !isHoveredConnectorPartOfProps) {
|
||||
// Ensures to reset the mouseEventsInfo in case the hoveredConnector is set but is no more part
|
||||
// of the new props.
|
||||
delete this.mouseEventsInfo.hoveredConnector;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the ConnectorContainer's parent required EventListeners.
|
||||
*
|
||||
*/
|
||||
_removeParentListeners() {
|
||||
this.parentElement.removeEventListener('mousedown', this._onParentMouseDownHandler);
|
||||
this.parentElement.removeEventListener('mousemove', this._throttledOnParentMouseOverHandler);
|
||||
this.parentElement.removeEventListener('mouseup', this._onParentMouseUpHandler);
|
||||
this.parentElement.removeEventListener('mouseleave', this._onParentMouseUpHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the hover state of the connector and render
|
||||
*
|
||||
* @param {string} id the id of the connector which hover state has to be updated.
|
||||
* @param {boolean} hovered the hover state to be set.
|
||||
* @private
|
||||
*/
|
||||
_updateConnectorHoverState(id, hovered) {
|
||||
this.connectors[id].hovered = hovered && !(this.props.preventHoverEffect || this.mouseEventsInfo.isParentDragging);
|
||||
if (hovered) {
|
||||
// When a connector is hover we need to ensure it is rendered as last element as svg z-index works
|
||||
// that way and unfortunately no css can be used to modify it.
|
||||
const hoverConnector = this.connectors[id];
|
||||
delete this.connectors[id];
|
||||
this.connectors[id] = hoverConnector;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Public
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gets the top, right, bottom and left anchors positions for the provided element with respect to the
|
||||
* ConnectorContainer's parent.
|
||||
*
|
||||
* @param {HTMLElement} element the element the anchors positions have to be calculated for.
|
||||
* @param {HTMLElement} container the container the anchors positions will be calculated with respect to. In order
|
||||
* to have a valid result, the container should be an element with position attribute
|
||||
* set to relative.
|
||||
* @returns {{
|
||||
* top: {top: number, left: number},
|
||||
* left: {top: number, left: number},
|
||||
* bottom: {top: number, left: number},
|
||||
* right: {top: number, left: number}
|
||||
* }}
|
||||
*/
|
||||
getAnchorsPositions(element) {
|
||||
const elementPosition = this._getElementPosition(element);
|
||||
return {
|
||||
top: {
|
||||
top: elementPosition.top,
|
||||
left: elementPosition.left + element.offsetWidth / 2,
|
||||
},
|
||||
right: {
|
||||
top: elementPosition.top + element.offsetHeight / 2,
|
||||
left: elementPosition.left + element.offsetWidth,
|
||||
},
|
||||
bottom: {
|
||||
top: elementPosition.top + element.offsetHeight,
|
||||
left: elementPosition.left + element.offsetWidth / 2,
|
||||
},
|
||||
left: {
|
||||
top: elementPosition.top + element.offsetHeight / 2,
|
||||
left: elementPosition.left,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handler for the ConnectorContainer's parent mousedown event. This handle is responsible of managing the start of a possible
|
||||
* connector creation (depending on whether the event target matches the sourceQuerySelector).
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
* @private
|
||||
*/
|
||||
_onParentMouseDown(ev) {
|
||||
const connector_source = ev.target.closest(this.props.sourceQuerySelector);
|
||||
if (connector_source) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.mouseEventsInfo.isParentDragging = true;
|
||||
const anchors = this.getAnchorsPositions(ev.target);
|
||||
this.newConnector = deepMerge(
|
||||
this.newConnector,
|
||||
{
|
||||
data: {
|
||||
sourceElement: connector_source,
|
||||
},
|
||||
inCreation: true,
|
||||
source: {
|
||||
top: anchors.right.top,
|
||||
left: anchors.right.left,
|
||||
},
|
||||
target: {
|
||||
top: anchors.right.top + ev.offsetY,
|
||||
left: anchors.right.left + ev.offsetX,
|
||||
},
|
||||
});
|
||||
this.props.onCreationStart(deepMerge({ }, this.newConnector));
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the ConnectorContainer's parent mouseover event. This handle is responsible of the update of the newConnector
|
||||
* component props if a connector creation has started.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
* @private
|
||||
*/
|
||||
_onParentMouseOver(ev) {
|
||||
if (this.mouseEventsInfo.isParentDragging === true) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const position = this._getElementPosition(ev.target);
|
||||
this.newConnector = deepMerge(
|
||||
this.newConnector,
|
||||
{
|
||||
target: {
|
||||
top: position.top + ev.offsetY,
|
||||
left: position.left + ev.offsetX,
|
||||
},
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the ConnectorContainer's parent mouseup event. This handle is responsible of triggering either the
|
||||
* connector-creation-done or connector-creation-abort (depending on whether the event target matches the
|
||||
* targetQuerySelector) if a connector creation has started.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
* @private
|
||||
*/
|
||||
_onParentMouseUp(ev) {
|
||||
if (this.mouseEventsInfo.isParentDragging === true) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const connector_target = ev.target.closest(this.props.targetQuerySelector || this.props.sourceQuerySelector);
|
||||
if (connector_target) {
|
||||
this.newConnector = deepMerge(
|
||||
this.newConnector,
|
||||
{
|
||||
data: {
|
||||
targetElement: connector_target,
|
||||
},
|
||||
});
|
||||
this.props.onCreationDone(deepMerge({ }, this.newConnector));
|
||||
} else {
|
||||
this.props.onCreationAbort(deepMerge({ }, this.newConnector));
|
||||
}
|
||||
this.mouseEventsInfo.isParentDragging = false;
|
||||
delete this.newConnector.source;
|
||||
delete this.newConnector.target;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the connector_manager svg mouseout event. Its purpose is to handle the hover state of the connectors.
|
||||
* It has been implemented here in order to manage it globally instead of in each connector (and thus limiting
|
||||
* the number of listeners).
|
||||
*
|
||||
* @param {OwlEvent} ev
|
||||
* @private
|
||||
*/
|
||||
_onMouseOut(ev) {
|
||||
ev.stopPropagation();
|
||||
if (!('hoveredConnector' in this.mouseEventsInfo)) {
|
||||
// If hoverConnector is not set this means were not in a connector. So ignore it.
|
||||
return;
|
||||
}
|
||||
let relatedTarget = ev.relatedTarget;
|
||||
while (relatedTarget) {
|
||||
// Go up the parent chain
|
||||
if (relatedTarget === this.mouseEventsInfo.hoveredConnector) {
|
||||
// Check that we are still inside hoveredConnector.
|
||||
// If so it means it is a transition between child elements so ignore it.
|
||||
return;
|
||||
}
|
||||
relatedTarget = relatedTarget.parentElement;
|
||||
}
|
||||
this._updateConnectorHoverState(this.mouseEventsInfo.hoveredConnector.dataset.id, false);
|
||||
this.props.onMouseOut(this.connectors[this.mouseEventsInfo.hoveredConnector.dataset.id]);
|
||||
delete this.mouseEventsInfo.hoveredConnector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the connector_manager svg mouseover event. Its purpose is to handle the hover state of the connectors.
|
||||
* It has been implemented here in order to manage it globally instead of in each connector (and thus limiting
|
||||
* the number of listeners).
|
||||
*
|
||||
* @param {OwlEvent} ev
|
||||
* @private
|
||||
*/
|
||||
_onMouseOver(ev) {
|
||||
ev.stopPropagation();
|
||||
if ('hoveredConnector' in this.mouseEventsInfo) {
|
||||
// As mouseout is call prior to mouseover, if hoveredConnector is set this means
|
||||
// that we haven't left it. So it's a mouseover inside it.
|
||||
return;
|
||||
}
|
||||
let target = ev.target.closest('.o_connector');
|
||||
if (!target) {
|
||||
// We are not into a connector si ignore.
|
||||
return;
|
||||
}
|
||||
if (!(target.dataset.id in this.connectors) || Object.is(this.connectors[target.dataset.id], this.newConnector)) {
|
||||
// We ensure that the connector to hover is not this.newConnector
|
||||
return;
|
||||
}
|
||||
this.mouseEventsInfo.hoveredConnector = target;
|
||||
this._updateConnectorHoverState(target.dataset.id, true);
|
||||
this.props.onMouseOver(this.connectors[target.dataset.id]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(ConnectorContainer, {
|
||||
components: { Connector },
|
||||
props: {
|
||||
connectors: { type: Object },
|
||||
defaultStyle: Connector.props.style,
|
||||
hoverEaseWidth: {
|
||||
optional: true,
|
||||
type: Number,
|
||||
},
|
||||
newConnectorStyle: Connector.props.style,
|
||||
preventHoverEffect: {
|
||||
optional: true,
|
||||
type: Boolean
|
||||
},
|
||||
sourceQuerySelector: { type: String },
|
||||
targetQuerySelector: {
|
||||
optional: true,
|
||||
type: String,
|
||||
},
|
||||
onRemoveButtonClick: { type: Function, optional: true },
|
||||
onRescheduleSoonerButtonClick: { type: Function, optional: true },
|
||||
onRescheduleLaterButtonClick: { type: Function, optional: true },
|
||||
onCreationAbort: { type: Function, optional: true },
|
||||
onCreationDone: { type: Function, optional: true },
|
||||
onCreationStart: { type: Function, optional: true },
|
||||
onMouseOut: { type: Function, optional: true },
|
||||
onMouseOver: { type: Function, optional: true },
|
||||
},
|
||||
defaultProps: {
|
||||
onRemoveButtonClick: () => {},
|
||||
onRescheduleSoonerButtonClick: () => {},
|
||||
onRescheduleLaterButtonClick: () => {},
|
||||
onCreationAbort: () => {},
|
||||
onCreationDone: () => {},
|
||||
onCreationStart: () => {},
|
||||
onMouseOut: () => {},
|
||||
onMouseOver: () => {},
|
||||
},
|
||||
template: 'connector_container',
|
||||
});
|
||||
|
||||
export default ConnectorContainer;
|
||||
127
web_gantt/static/src/js/connector/connector_utils.js
Normal file
127
web_gantt/static/src/js/connector/connector_utils.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Clones the value according to its type and returns it.
|
||||
*
|
||||
* @param value
|
||||
* @return {Object|Array|*} The cloned value.
|
||||
* @private
|
||||
*/
|
||||
function _clone(value) {
|
||||
if (_isPlainObject(value) || Array.isArray(value)) {
|
||||
return deepMerge(_getEmptyTarget(value), value);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an empty value according to value's type.
|
||||
*
|
||||
* @param {Object | Array} value
|
||||
* @return {Object | Array} an empty Array or Object.
|
||||
* @private
|
||||
*/
|
||||
function _getEmptyTarget(value) {
|
||||
return Array.isArray(value) ? [] : { };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the provided argument is a plain object or not.
|
||||
*
|
||||
* @param {*} value
|
||||
* @returns {boolean} true if the provided argument is a plain object, false if not.
|
||||
*/
|
||||
function _isPlainObject(value) {
|
||||
if (typeof value == 'object' && value !== null) {
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges target and source arrays and returns the result.
|
||||
*
|
||||
* @param {Array} target
|
||||
* @param {Array} source
|
||||
* @return {Array} the result of the merge.
|
||||
* @private
|
||||
*/
|
||||
function _deepMergeArray(target, source) {
|
||||
return target.concat(source)
|
||||
.map((entry) => _clone(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges target and source Objects and returns the result
|
||||
*
|
||||
* @param {Object} target
|
||||
* @param {Object} source
|
||||
* @return {Object} the result of the merge.
|
||||
*/
|
||||
function _mergeObject(target, source) {
|
||||
const destination = { };
|
||||
if (_isPlainObject(target)) {
|
||||
Object.keys(target).forEach((key) => {
|
||||
destination[key] = _clone(target[key]);
|
||||
});
|
||||
}
|
||||
Object.keys(source).forEach((key) => {
|
||||
if ((_isPlainObject(target) && key in target) && _isPlainObject(source[key])) {
|
||||
destination[key] = deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
destination[key] = _clone(source[key]);
|
||||
}
|
||||
});
|
||||
return destination;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Public
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deep merges target and source and returns the result.
|
||||
* This implementation has been added since vanilla JS is now preferred.
|
||||
* A deep copy is made of all the plain objects and arrays.
|
||||
* For the other type of objects (like HTMLElement, etc.), the reference is passed.
|
||||
*
|
||||
* @param {Object | Array} target
|
||||
* @param {Object | Array} [source] if source is undefined, target wil be set to an empty value of source type.
|
||||
* @returns {Object | Array} the result of the merge.
|
||||
*/
|
||||
export function deepMerge(target, source) {
|
||||
if (typeof source === 'undefined') {
|
||||
source = _getEmptyTarget(source);
|
||||
}
|
||||
|
||||
const isSourceAnArray = Array.isArray(source);
|
||||
const isTargetAnArray = Array.isArray(target);
|
||||
|
||||
if (isSourceAnArray !== isTargetAnArray) {
|
||||
return _clone(source);
|
||||
} else if (isSourceAnArray) {
|
||||
return _deepMergeArray(target, source);
|
||||
} else {
|
||||
return _mergeObject(target, source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges all the entries from the passed array and returns the result.
|
||||
*
|
||||
* @param {Array} array the elements to be merged together.
|
||||
* @return {Object | Array} the result of the merge.
|
||||
*/
|
||||
export function deepMergeAll(array) {
|
||||
if (!Array.isArray(array)) {
|
||||
throw new Error('deepmergeAll argument must be an Array.');
|
||||
}
|
||||
return array.reduce((accumulator, current) => deepMerge(accumulator, current), { });
|
||||
}
|
||||
684
web_gantt/static/src/js/gantt_controller.js
Normal file
684
web_gantt/static/src/js/gantt_controller.js
Normal file
@@ -0,0 +1,684 @@
|
||||
/** @odoo-module alias=web_gantt.GanttController */
|
||||
|
||||
import AbstractController from 'web.AbstractController';
|
||||
import core from 'web.core';
|
||||
import config from 'web.config';
|
||||
import { confirm as confirmDialog } from 'web.Dialog';
|
||||
import { Domain } from '@web/core/domain';
|
||||
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
|
||||
const QWeb = core.qweb;
|
||||
const _t = core._t;
|
||||
const { Component } = owl;
|
||||
|
||||
export function removeDomainLeaf(domain, keysToRemove) {
|
||||
function processLeaf(elements, idx, operatorCtx, newDomain) {
|
||||
const leaf = elements[idx];
|
||||
if (leaf.type === 10) {
|
||||
if (keysToRemove.includes(leaf.value[0].value)) {
|
||||
if (operatorCtx === '&') {
|
||||
newDomain.ast.value.push(...Domain.TRUE.ast.value);
|
||||
} else if (operatorCtx === '|') {
|
||||
newDomain.ast.value.push(...Domain.FALSE.ast.value);
|
||||
}
|
||||
} else {
|
||||
newDomain.ast.value.push(leaf);
|
||||
}
|
||||
return 1;
|
||||
} else if (leaf.type === 1) {
|
||||
// Special case to avoid OR ('|') that can never resolve to true
|
||||
if (leaf.value === '|' && elements[idx + 1].type === 10 && elements[idx + 2].type === 10
|
||||
&& keysToRemove.includes(elements[idx + 1].value[0].value)
|
||||
&& keysToRemove.includes(elements[idx + 2].value[0].value)
|
||||
) {
|
||||
newDomain.ast.value.push(...Domain.TRUE.ast.value);
|
||||
return 3;
|
||||
}
|
||||
newDomain.ast.value.push(leaf);
|
||||
if (leaf.value === '!') {
|
||||
return 1 + processLeaf(elements, idx + 1, '&', newDomain);
|
||||
}
|
||||
const firstLeafSkip = processLeaf(elements, idx + 1, leaf.value, newDomain);
|
||||
const secondLeafSkip = processLeaf(elements, idx + 1 + firstLeafSkip, leaf.value, newDomain);
|
||||
return 1 + firstLeafSkip + secondLeafSkip;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
domain = new Domain(domain);
|
||||
if (domain.ast.value.length === 0) {
|
||||
return domain;
|
||||
}
|
||||
const newDomain = new Domain([]);
|
||||
processLeaf(domain.ast.value, 0, '&', newDomain);
|
||||
return newDomain;
|
||||
}
|
||||
|
||||
export default AbstractController.extend({
|
||||
events: _.extend({}, AbstractController.prototype.events, {
|
||||
'click .o_gantt_button_add': '_onAddClicked',
|
||||
'click .o_gantt_button_scale': '_onScaleClicked',
|
||||
'click .o_gantt_button_prev': '_onPrevPeriodClicked',
|
||||
'click .o_gantt_button_next': '_onNextPeriodClicked',
|
||||
'click .o_gantt_button_today': '_onTodayClicked',
|
||||
'click .o_gantt_button_expand_rows': '_onExpandClicked',
|
||||
'click .o_gantt_button_collapse_rows': '_onCollapseClicked',
|
||||
}),
|
||||
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
|
||||
add_button_clicked: '_onCellAddClicked',
|
||||
collapse_row: '_onCollapseRow',
|
||||
expand_row: '_onExpandRow',
|
||||
on_connector_end_drag: '_onConnectorEndDrag',
|
||||
on_connector_highlight: '_onConnectorHighlight',
|
||||
on_connector_start_drag: '_onConnectorStartDrag',
|
||||
on_create_connector: '_onCreateConnector',
|
||||
on_pill_highlight: '_onPillHighlight',
|
||||
on_remove_connector: '_onRemoveConnector',
|
||||
on_reschedule_according_to_dependency: '_onRescheduleAccordingToDependency',
|
||||
pill_clicked: '_onPillClicked',
|
||||
pill_resized: '_onPillResized',
|
||||
pill_dropped: '_onPillDropped',
|
||||
plan_button_clicked: '_onCellPlanClicked',
|
||||
updating_pill_started: '_onPillUpdatingStarted',
|
||||
updating_pill_stopped: '_onPillUpdatingStopped',
|
||||
}),
|
||||
buttonTemplateName: 'GanttView.buttons',
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Widget} parent
|
||||
* @param {GanttModel} model
|
||||
* @param {GanttRenderer} renderer
|
||||
* @param {Object} params
|
||||
* @param {Object} params.context
|
||||
* @param {Array[]} params.dialogViews
|
||||
* @param {Object} params.SCALES
|
||||
* @param {boolean} params.collapseFirstLevel
|
||||
*/
|
||||
init(parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.model = model;
|
||||
this.context = params.context;
|
||||
this.dialogViews = params.dialogViews;
|
||||
this.SCALES = params.SCALES;
|
||||
this.allowedScales = params.allowedScales;
|
||||
this.collapseFirstLevel = params.collapseFirstLevel;
|
||||
this.createAction = params.createAction;
|
||||
this.actionDomain = params.actionDomain;
|
||||
this._draggingConnector = false;
|
||||
|
||||
this.isRTL = _t.database.parameters.direction === "rtl";
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {jQuery} [$node] to which the buttons will be appended
|
||||
*/
|
||||
renderButtons($node) {
|
||||
this.$buttons = this._renderButtonsQWeb();
|
||||
if ($node) {
|
||||
this.$buttons.appendTo($node);
|
||||
}
|
||||
},
|
||||
_renderButtonsQWeb() {
|
||||
return $(QWeb.render(this.buttonTemplateName, this._renderButtonQWebParameter()));
|
||||
},
|
||||
_renderButtonQWebParameter() {
|
||||
const state = this.model.get();
|
||||
const nbGroups = state.groupedBy.length;
|
||||
const minNbGroups = this.collapseFirstLevel ? 0 : 1;
|
||||
const displayExpandCollapseButtons = nbGroups > minNbGroups;
|
||||
return {
|
||||
groupedBy: state.groupedBy,
|
||||
widget: this,
|
||||
SCALES: this.SCALES,
|
||||
activateScale: state.scale,
|
||||
allowedScales: this.allowedScales,
|
||||
displayExpandCollapseButtons: displayExpandCollapseButtons,
|
||||
isMobile: config.device.isMobile,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
updateButtons() {
|
||||
if (!this.$buttons) {
|
||||
return;
|
||||
}
|
||||
this.$buttons.html(this._renderButtonsQWeb());
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {integer} id
|
||||
* @param {Object} schedule
|
||||
*/
|
||||
_copy(id, schedule) {
|
||||
return this._executeAsyncOperation(
|
||||
this.model.copy.bind(this.model),
|
||||
[id, schedule]
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {function} operation
|
||||
* @param {Array} args
|
||||
*/
|
||||
_executeAsyncOperation(operation, args) {
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
const asyncOp = operation(...args);
|
||||
asyncOp.then(resolve).guardedCatch(resolve);
|
||||
this.dp.add(asyncOp).guardedCatch(reject);
|
||||
});
|
||||
return prom.then(this.reload.bind(this, {}));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} event
|
||||
*/
|
||||
_getDialogContext(date, rowId) {
|
||||
const state = this.model.get();
|
||||
const context = {};
|
||||
context[state.dateStartField] = date.clone();
|
||||
context[state.dateStopField] = date.clone().endOf(this.SCALES[state.scale].interval);
|
||||
if (rowId) {
|
||||
// Default values of the group this cell belongs in
|
||||
// We can read them from any pill in this group row
|
||||
for (const fieldName of state.groupedBy) {
|
||||
const groupValue = Object.assign({}, ...JSON.parse(rowId));
|
||||
let value = groupValue[fieldName];
|
||||
if (Array.isArray(value)) {
|
||||
const { type: fieldType } = state.fields[fieldName];
|
||||
if (fieldType === "many2many") {
|
||||
value = [value[0]];
|
||||
} else if (fieldType === "many2one") {
|
||||
value = value[0];
|
||||
}
|
||||
}
|
||||
if (value !== undefined) {
|
||||
context[fieldName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// moment context dates needs to be converted in server time in view
|
||||
// dialog (for default values)
|
||||
for (const k in context) {
|
||||
const type = state.fields[k].type;
|
||||
if (context[k] && (type === 'datetime' || type === 'date')) {
|
||||
context[k] = this.model.convertToServerTime(context[k]);
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
},
|
||||
/**
|
||||
* Opens dialog to add/edit/view a record
|
||||
*
|
||||
* @private
|
||||
* @param {Object} props FormViewDialog props
|
||||
* @param {Object} options
|
||||
*/
|
||||
_openDialog(props, options = {}) {
|
||||
const title = props.title || (props.resId ? _t("Open") : _t("Create"));
|
||||
const onClose = options.onClose || (() => {});
|
||||
options = {
|
||||
...options,
|
||||
onClose: async () => {
|
||||
onClose();
|
||||
await this.reload();
|
||||
},
|
||||
};
|
||||
let removeRecord;
|
||||
if (this.is_action_enabled('delete') && props.resId) {
|
||||
removeRecord = this._onDialogRemove.bind(this, props.resId)
|
||||
}
|
||||
Component.env.services.dialog.add(FormViewDialog, {
|
||||
title,
|
||||
resModel: this.modelName,
|
||||
viewId: this.dialogViews[0][0],
|
||||
resId: props.resId,
|
||||
mode: this.is_action_enabled('edit') ? "edit" : "readonly",
|
||||
context: _.extend({}, this.context, props.context),
|
||||
removeRecord
|
||||
}, options);
|
||||
},
|
||||
/**
|
||||
* Handler called when clicking the
|
||||
* delete button in the edit/view dialog.
|
||||
* Reload the view and close the dialog
|
||||
*
|
||||
* @returns {function}
|
||||
*/
|
||||
_onDialogRemove(resID) {
|
||||
const confirm = new Promise((resolve) => {
|
||||
confirmDialog(this, _t('Are you sure to delete this record?'), {
|
||||
confirm_callback: () => {
|
||||
resolve(true);
|
||||
},
|
||||
cancel_callback: () => {
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return confirm.then((confirmed) => {
|
||||
if ((!confirmed)) {
|
||||
return Promise.resolve();
|
||||
}// else
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'unlink',
|
||||
args: [[resID,],],
|
||||
}).then(() => {
|
||||
return this.reload();
|
||||
})
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get domain of records for plan dialog in the gantt view.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getPlanDialogDomain(state) {
|
||||
const newDomain = removeDomainLeaf(
|
||||
this.actionDomain,
|
||||
[state.dateStartField, state.dateStopField]
|
||||
);
|
||||
return Domain.and([
|
||||
newDomain,
|
||||
['|', [state.dateStartField, '=', false], [state.dateStopField, '=', false]],
|
||||
]).toList({});
|
||||
},
|
||||
/**
|
||||
* Opens dialog to plan records.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} context
|
||||
*/
|
||||
_openPlanDialog(context) {
|
||||
const state = this.model.get();
|
||||
Component.env.services.dialog.add(SelectCreateDialog, {
|
||||
title: _t("Plan"),
|
||||
resModel: this.modelName,
|
||||
domain: this._getPlanDialogDomain(state),
|
||||
views: this.dialogViews,
|
||||
context: Object.assign({}, this.context, context),
|
||||
onSelected: (resIds) => {
|
||||
if (resIds.length) {
|
||||
// Here, the dates are already in server time so we set the
|
||||
// isUTC parameter of reschedule to true to avoid conversion
|
||||
this._reschedule(resIds, context, true, this.openPlanDialogCallback);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
/**
|
||||
* upon clicking on the create button, determines if a dialog with a formview should be opened
|
||||
* or if a wizard should be openned, then opens it
|
||||
*
|
||||
* @param {object} context
|
||||
*/
|
||||
_onCreate(context) {
|
||||
if (this.createAction) {
|
||||
const fullContext = Object.assign({}, this.context, context);
|
||||
this.do_action(this.createAction, {
|
||||
additional_context: fullContext,
|
||||
on_close: this.reload.bind(this, {})
|
||||
});
|
||||
} else {
|
||||
this._openDialog({ context });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Reschedule records and reload.
|
||||
*
|
||||
* Use a DropPrevious to prevent unnecessary reload and rendering.
|
||||
*
|
||||
* Note that when the rpc fails, we have to reload and re-render as some
|
||||
* records might be outdated, causing the rpc failure).
|
||||
*
|
||||
* @private
|
||||
* @param {integer[]|integer} ids
|
||||
* @param {Object} schedule
|
||||
* @param {boolean} isUTC
|
||||
* @returns {Promise} resolved when the record has been reloaded, rejected
|
||||
* if the request has been dropped by DropPrevious
|
||||
*/
|
||||
_reschedule(ids, schedule, isUTC, callback) {
|
||||
return this._executeAsyncOperation(
|
||||
this.model.reschedule.bind(this.model),
|
||||
[ids, schedule, isUTC, callback]
|
||||
);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens a dialog to create a new record.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onCellAddClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
const context = this._getDialogContext(ev.data.date, ev.data.rowId);
|
||||
for (const k in context) {
|
||||
context[_.str.sprintf('default_%s', k)] = context[k];
|
||||
}
|
||||
this._onCreate(context);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAddClicked(ev) {
|
||||
ev.preventDefault();
|
||||
const context = {};
|
||||
const state = this.model.get();
|
||||
context[state.dateStartField] = this.model.convertToServerTime(state.focusDate.clone().startOf(state.scale));
|
||||
context[state.dateStopField] = this.model.convertToServerTime(state.focusDate.clone().endOf(state.scale));
|
||||
for (const k in context) {
|
||||
context[_.str.sprintf('default_%s', k)] = context[k];
|
||||
}
|
||||
this._onCreate(context);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onCollapseClicked(ev) {
|
||||
ev.preventDefault();
|
||||
this.model.collapseRows();
|
||||
this.update({}, { reload: false });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {string} ev.data.rowId
|
||||
*/
|
||||
_onCollapseRow(ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.collapseRow(ev.data.rowId);
|
||||
this.renderer.updateRow(this.model.get(ev.data.rowId));
|
||||
},
|
||||
/**
|
||||
* Handler for renderer on-connector-end-drag event.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @private
|
||||
*/
|
||||
_onConnectorEndDrag(ev) {
|
||||
ev.stopPropagation();
|
||||
this._draggingConnector = false;
|
||||
this.renderer.set_connector_creation_mode(this._draggingConnector);
|
||||
},
|
||||
/**
|
||||
* Handler for renderer on-connector-highlight event.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @private
|
||||
*/
|
||||
_onConnectorHighlight(ev) {
|
||||
ev.stopPropagation();
|
||||
if (!this._updating && !this._draggingConnector) {
|
||||
this.renderer.toggleConnectorHighlighting(ev.data.connector, ev.data.highlighted);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handler for renderer on-connector-start-drag event.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @private
|
||||
*/
|
||||
_onConnectorStartDrag(ev) {
|
||||
ev.stopPropagation();
|
||||
this._draggingConnector = true;
|
||||
this.renderer.set_connector_creation_mode(this._draggingConnector);
|
||||
},
|
||||
/**
|
||||
* Handler for renderer on-create-connector event.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @returns {Promise<*>}
|
||||
* @private
|
||||
*/
|
||||
async _onCreateConnector(ev) {
|
||||
ev.stopPropagation();
|
||||
await this.model.createDependency(ev.data.masterId, ev.data.slaveId);
|
||||
await this.reload();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onExpandClicked(ev) {
|
||||
ev.preventDefault();
|
||||
this.model.expandRows();
|
||||
this.update({}, { reload: false });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {string} ev.data.rowId
|
||||
*/
|
||||
_onExpandRow(ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.expandRow(ev.data.rowId);
|
||||
this.renderer.updateRow(this.model.get(ev.data.rowId));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onNextPeriodClicked(ev) {
|
||||
ev.preventDefault();
|
||||
const state = this.model.get();
|
||||
this.update({ date: state.focusDate.add(1, state.scale) });
|
||||
},
|
||||
/**
|
||||
* Opens dialog when clicked on pill to view record.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {jQuery} ev.data.target
|
||||
*/
|
||||
async _onPillClicked(ev) {
|
||||
if (!this._updating) {
|
||||
ev.data.target.addClass('o_gantt_pill_editing');
|
||||
|
||||
// Sync with the mutex to wait for potential changes on the view
|
||||
await this.model.mutex.getUnlockedDef();
|
||||
|
||||
const props = { resId: ev.data.target.data('id') };
|
||||
const options = { onClose: () => ev.data.target.removeClass('o_gantt_pill_editing') };
|
||||
this._openDialog(props, options);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Saves pill information when dragged.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {Object} ev.data
|
||||
* @param {integer} [ev.data.diff]
|
||||
* @param {integer} [ev.data.groupLevel]
|
||||
* @param {string} [ev.data.pillId]
|
||||
* @param {string} [ev.data.newRowId]
|
||||
* @param {string} [ev.data.oldRowId]
|
||||
* @param {'copy'|'reschedule'} [ev.data.action]
|
||||
*/
|
||||
_onPillDropped(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const state = this.model.get();
|
||||
|
||||
const schedule = {};
|
||||
|
||||
let diff = ev.data.diff;
|
||||
diff = this.isRTL ? -diff : diff;
|
||||
if (diff) {
|
||||
const pill = _.findWhere(state.records, { id: ev.data.pillId });
|
||||
schedule[state.dateStartField] = this.model.dateAdd(pill[state.dateStartField], diff, this.SCALES[state.scale].time);
|
||||
schedule[state.dateStopField] = this.model.dateAdd(pill[state.dateStopField], diff, this.SCALES[state.scale].time);
|
||||
} else if (ev.data.action === 'copy') {
|
||||
// When we copy the info on dates is sometimes mandatory (e.g. working on hr.leave, see copy_data)
|
||||
const pill = _.findWhere(state.records, { id: ev.data.pillId });
|
||||
schedule[state.dateStartField] = pill[state.dateStartField].clone();
|
||||
schedule[state.dateStopField] = pill[state.dateStopField].clone();
|
||||
}
|
||||
|
||||
if (ev.data.newRowId && ev.data.newRowId !== ev.data.oldRowId) {
|
||||
const groupValue = Object.assign({}, ...JSON.parse(ev.data.newRowId));
|
||||
|
||||
// if the pill is dragged in a top level group, we only want to
|
||||
// write on fields linked to this top level group
|
||||
const fieldsToWrite = state.groupedBy.slice(0, ev.data.groupLevel + 1);
|
||||
for (const fieldName of fieldsToWrite) {
|
||||
// TODO: maybe not write if the value hasn't changed?
|
||||
let valueToWrite = groupValue[fieldName];
|
||||
if (Array.isArray(valueToWrite)) {
|
||||
const { type: fieldType } = state.fields[fieldName];
|
||||
if (fieldType === "many2many") {
|
||||
valueToWrite = [valueToWrite[0]];
|
||||
} else if (fieldType === "many2one") {
|
||||
valueToWrite = valueToWrite[0];
|
||||
}
|
||||
}
|
||||
schedule[fieldName] = valueToWrite;
|
||||
}
|
||||
}
|
||||
if (ev.data.action === 'copy') {
|
||||
this._copy(ev.data.pillId, schedule);
|
||||
} else {
|
||||
this._reschedule(ev.data.pillId, schedule);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handler for renderer on-connector-end-drag event.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @private
|
||||
*/
|
||||
async _onPillHighlight(ev) {
|
||||
ev.stopPropagation();
|
||||
if (!this._updating || !ev.data.highlighted) {
|
||||
await this.renderer.togglePillHighlighting(ev.data.element, ev.data.highlighted);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Save pill information when resized
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onPillResized(ev) {
|
||||
ev.stopPropagation();
|
||||
const schedule = {};
|
||||
schedule[ev.data.field] = ev.data.date;
|
||||
this._reschedule(ev.data.id, schedule);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onPillUpdatingStarted(ev) {
|
||||
ev.stopPropagation();
|
||||
this._updating = true;
|
||||
this.renderer.togglePreventConnectorsHoverEffect(true);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onPillUpdatingStopped(ev) {
|
||||
ev.stopPropagation();
|
||||
this._updating = false;
|
||||
this.renderer.togglePreventConnectorsHoverEffect(false);
|
||||
},
|
||||
/**
|
||||
* Opens a dialog to plan records.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onCellPlanClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
const context = this._getDialogContext(ev.data.date, ev.data.rowId);
|
||||
this._openPlanDialog(context);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onPrevPeriodClicked(ev) {
|
||||
ev.preventDefault();
|
||||
const state = this.model.get();
|
||||
this.update({ date: state.focusDate.subtract(1, state.scale) });
|
||||
},
|
||||
/**
|
||||
* Handler for renderer on-remove-connector event.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @private
|
||||
*/
|
||||
async _onRemoveConnector(ev) {
|
||||
ev.stopPropagation();
|
||||
await this.model.removeDependency(ev.data.masterId, ev.data.slaveId);
|
||||
await this.reload();
|
||||
},
|
||||
/**
|
||||
* Handler for renderer on-reschedule-according-to-dependency event.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
* @private
|
||||
*/
|
||||
async _onRescheduleAccordingToDependency(ev) {
|
||||
ev.stopPropagation();
|
||||
const result = await this.model.rescheduleAccordingToDependency(
|
||||
ev.data.direction,
|
||||
ev.data.masterId,
|
||||
ev.data.slaveId);
|
||||
if (result === false) {
|
||||
return
|
||||
} else {
|
||||
await this.reload();
|
||||
if (result.type == 'ir.actions.client') {
|
||||
this.do_action(result);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onScaleClicked(ev) {
|
||||
ev.preventDefault();
|
||||
const $button = $(ev.currentTarget);
|
||||
if ($button.hasClass('active')) {
|
||||
return;
|
||||
}
|
||||
this.update({ scale: $button.data('value') });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onTodayClicked(ev) {
|
||||
ev.preventDefault();
|
||||
this.update({ date: moment() });
|
||||
},
|
||||
});
|
||||
793
web_gantt/static/src/js/gantt_model.js
Normal file
793
web_gantt/static/src/js/gantt_model.js
Normal file
@@ -0,0 +1,793 @@
|
||||
/** @odoo-module alias=web_gantt.GanttModel */
|
||||
|
||||
import AbstractModel from 'web.AbstractModel';
|
||||
import { x2ManyCommands } from '@web/core/orm_service';
|
||||
import concurrency from 'web.concurrency';
|
||||
import core from 'web.core';
|
||||
import fieldUtils from 'web.field_utils';
|
||||
import { findWhere, groupBy } from 'web.utils';
|
||||
import session from 'web.session';
|
||||
|
||||
const _t = core._t;
|
||||
|
||||
export default AbstractModel.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init(parent, params = {}) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.dp = new concurrency.DropPrevious();
|
||||
this.mutex = new concurrency.Mutex();
|
||||
this.dependencyField = params.dependencyField;
|
||||
this.dependencyInvertedField = params.dependencyInvertedField;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collapses the given row.
|
||||
*
|
||||
* @param {string} rowId
|
||||
*/
|
||||
collapseRow(rowId) {
|
||||
this.allRows[rowId].isOpen = false;
|
||||
},
|
||||
/**
|
||||
* Collapses all rows (first level only).
|
||||
*/
|
||||
collapseRows() {
|
||||
this.ganttData.rows.forEach((group) => {
|
||||
group.isOpen = false;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Convert date to server timezone
|
||||
*
|
||||
* @param {Moment} date
|
||||
* @returns {string} date in server format
|
||||
*/
|
||||
convertToServerTime(date) {
|
||||
const result = date.clone();
|
||||
if (!result.isUTC()) {
|
||||
result.subtract(session.getTZOffset(date), 'minutes');
|
||||
}
|
||||
return result.locale('en').format('YYYY-MM-DD HH:mm:ss');
|
||||
},
|
||||
/**
|
||||
* Adds a dependency between masterId and slaveId (slaveId depends
|
||||
* on masterId).
|
||||
*
|
||||
* @param masterId
|
||||
* @param slaveId
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
async createDependency(masterId, slaveId) {
|
||||
return this.mutex.exec(() => {
|
||||
const writeCommand = {};
|
||||
writeCommand[this.dependencyField] = [x2ManyCommands.linkTo(masterId)];
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'write',
|
||||
args: [[slaveId], writeCommand],
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Add or subtract value to a moment.
|
||||
* If we are changing by a whole day or more, adjust the time if needed to keep
|
||||
* the same local time, if the UTC offset has changed between the 2 dates
|
||||
* (usually, because of daylight savings)
|
||||
*
|
||||
* @param {Moment} date
|
||||
* @param {integer} offset
|
||||
* @param {string} unit
|
||||
*/
|
||||
dateAdd(date, offset, unit) {
|
||||
const result = date.clone().add(offset, unit);
|
||||
if(Math.abs(result.diff(date, 'hours')) >= 24) {
|
||||
const tzOffsetDiff = result.clone().local().utcOffset() - date.clone().local().utcOffset();
|
||||
if(tzOffsetDiff !== 0) {
|
||||
result.subtract(tzOffsetDiff, 'minutes');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {string} [rowId]
|
||||
* @returns {Object} the whole gantt data if no rowId given, the given row's
|
||||
* description otherwise
|
||||
*/
|
||||
__get(rowId) {
|
||||
if (rowId) {
|
||||
return this.allRows[rowId];
|
||||
} else {
|
||||
return Object.assign({ isSample: this.isSampleModel }, this.ganttData);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Expands the given row.
|
||||
*
|
||||
* @param {string} rowId
|
||||
*/
|
||||
expandRow(rowId) {
|
||||
this.allRows[rowId].isOpen = true;
|
||||
},
|
||||
/**
|
||||
* Expands all rows.
|
||||
*/
|
||||
expandRows() {
|
||||
Object.keys(this.allRows).forEach((rowId) => {
|
||||
const row = this.allRows[rowId];
|
||||
if (row.isGroup) {
|
||||
this.allRows[rowId].isOpen = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} params
|
||||
* @param {Object} params.context
|
||||
* @param {Object} params.colorField
|
||||
* @param {string} params.dateStartField
|
||||
* @param {string} params.dateStopField
|
||||
* @param {string[]} params.decorationFields
|
||||
* @param {string} params.defaultGroupBy
|
||||
* @param {string} params.permanentGroupBy
|
||||
* @param {boolean} params.displayUnavailability
|
||||
* @param {Array[]} params.domain
|
||||
* @param {Object} params.fields
|
||||
* @param {boolean} params.dynamicRange
|
||||
* @param {string[]} params.groupedBy
|
||||
* @param {Moment} params.initialDate
|
||||
* @param {string} params.modelName
|
||||
* @param {string} params.scale
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async __load(params) {
|
||||
await this._super(...arguments);
|
||||
this.modelName = params.modelName;
|
||||
this.fields = params.fields;
|
||||
this.domain = params.domain;
|
||||
this.context = params.context;
|
||||
this.decorationFields = params.decorationFields;
|
||||
this.colorField = params.colorField;
|
||||
this.progressField = params.progressField;
|
||||
this.consolidationParams = params.consolidationParams;
|
||||
this.collapseFirstLevel = params.collapseFirstLevel;
|
||||
this.displayUnavailability = params.displayUnavailability;
|
||||
this.SCALES = params.SCALES;
|
||||
this.progressBarFields = params.progressBarFields ? params.progressBarFields.split(",") : false;
|
||||
|
||||
this.defaultGroupBy = params.defaultGroupBy ? params.defaultGroupBy.split(',') : [];
|
||||
this.permanentGroupBy = params.permanentGroupBy
|
||||
let groupedBy = params.groupedBy;
|
||||
if (!groupedBy || !groupedBy.length) {
|
||||
groupedBy = this.defaultGroupBy;
|
||||
}
|
||||
if (this.permanentGroupBy && !groupedBy.includes(this.permanentGroupBy)) {
|
||||
groupedBy.push(this.permanentGroupBy)
|
||||
}
|
||||
groupedBy = this._filterDateInGroupedBy(groupedBy);
|
||||
|
||||
this.ganttData = {
|
||||
dateStartField: params.dateStartField,
|
||||
dateStopField: params.dateStopField,
|
||||
groupedBy,
|
||||
fields: params.fields,
|
||||
dynamicRange: params.dynamicRange,
|
||||
};
|
||||
this._setRange(params.initialDate, params.scale);
|
||||
return this._fetchData().then(() => {
|
||||
// The 'load' function returns a promise which resolves with the
|
||||
// handle to pass to the 'get' function to access the data. In this
|
||||
// case, we don't want to pass any argument to 'get' (see its API).
|
||||
return Promise.resolve();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {any} handle
|
||||
* @param {Object} params
|
||||
* @param {Array[]} params.domain
|
||||
* @param {string[]} params.groupBy
|
||||
* @param {string} params.scale
|
||||
* @param {Moment} params.date
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async __reload(handle, params) {
|
||||
await this._super(...arguments);
|
||||
if ('scale' in params) {
|
||||
this._setRange(this.ganttData.focusDate, params.scale);
|
||||
}
|
||||
if ('date' in params) {
|
||||
this._setRange(params.date, this.ganttData.scale);
|
||||
}
|
||||
if ('domain' in params) {
|
||||
this.domain = params.domain;
|
||||
}
|
||||
if ('groupBy' in params) {
|
||||
if (params.groupBy && params.groupBy.length) {
|
||||
this.ganttData.groupedBy = this._filterDateInGroupedBy(params.groupBy);
|
||||
if(this.ganttData.groupedBy.length !== params.groupBy.length){
|
||||
this.displayNotification({ message: _t('Grouping by date is not supported'), type: 'danger' });
|
||||
}
|
||||
if (this.permanentGroupBy && !this.ganttData.groupedBy.includes(this.permanentGroupBy)) {
|
||||
this.ganttData.groupedBy.push(this.permanentGroupBy)
|
||||
}
|
||||
} else {
|
||||
this.ganttData.groupedBy = this.defaultGroupBy;
|
||||
}
|
||||
}
|
||||
return this._fetchData().then(() => {
|
||||
// The 'reload' function returns a promise which resolves with the
|
||||
// handle to pass to the 'get' function to access the data. In this
|
||||
// case, we don't want to pass any argument to 'get' (see its API).
|
||||
return Promise.resolve();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Create a copy of a task with defaults determined by schedule.
|
||||
*
|
||||
* @param {integer} id
|
||||
* @param {Object} schedule
|
||||
* @returns {Promise}
|
||||
*/
|
||||
copy(id, schedule) {
|
||||
const defaults = this.rescheduleData(schedule);
|
||||
return this.mutex.exec(() => {
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'copy',
|
||||
args: [id, defaults],
|
||||
context: this.context,
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Removes the dependency between masterId and slaveId (slaveId is no
|
||||
* more dependent on masterId).
|
||||
*
|
||||
* @param masterId
|
||||
* @param slaveId
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
async removeDependency(masterId, slaveId) {
|
||||
return this.mutex.exec(() => {
|
||||
const writeCommand = {};
|
||||
writeCommand[this.dependencyField] = [x2ManyCommands.forget(masterId)];
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'write',
|
||||
args: [[slaveId], writeCommand],
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Reschedule a task to the given schedule.
|
||||
*
|
||||
* @param {integer} id
|
||||
* @param {Object} schedule
|
||||
* @param {boolean} isUTC
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reschedule(ids, schedule, isUTC, callback) {
|
||||
if (!_.isArray(ids)) {
|
||||
ids = [ids];
|
||||
}
|
||||
const data = this.rescheduleData(schedule, isUTC);
|
||||
return this.mutex.exec(() => {
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'write',
|
||||
args: [ids, data],
|
||||
context: this.context,
|
||||
}).then((result) => {
|
||||
if (callback) {
|
||||
callback(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Reschedule masterId or slaveId according to the direction
|
||||
*
|
||||
* @param direction
|
||||
* @param masterId
|
||||
* @param slaveId
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
async rescheduleAccordingToDependency(direction, masterId, slaveId) {
|
||||
return this.mutex.exec(() => {
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'web_gantt_reschedule',
|
||||
args: [
|
||||
direction,
|
||||
masterId,
|
||||
slaveId,
|
||||
this.dependencyField,
|
||||
this.dependencyInvertedField,
|
||||
this.ganttData.dateStartField,
|
||||
this.ganttData.dateStopField
|
||||
],
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {Object} schedule
|
||||
* @param {boolean} isUTC
|
||||
*/
|
||||
rescheduleData(schedule, isUTC) {
|
||||
const allowedFields = [
|
||||
this.ganttData.dateStartField,
|
||||
this.ganttData.dateStopField,
|
||||
...this.ganttData.groupedBy
|
||||
];
|
||||
|
||||
const data = _.pick(schedule, allowedFields);
|
||||
|
||||
let type;
|
||||
for (let k in data) {
|
||||
type = this.fields[k].type;
|
||||
if (data[k] && (type === 'datetime' || type === 'date') && !isUTC) {
|
||||
data[k] = this.convertToServerTime(data[k]);
|
||||
}
|
||||
};
|
||||
return data
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetches records to display (and groups if necessary).
|
||||
*
|
||||
* @private
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
_fetchData() {
|
||||
const domain = this._getDomain();
|
||||
const context = Object.assign({}, this.context, { group_by: this.ganttData.groupedBy });
|
||||
|
||||
let groupsDef;
|
||||
if (this.ganttData.groupedBy.length) {
|
||||
groupsDef = this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'read_group',
|
||||
fields: this._getFields(),
|
||||
domain: domain,
|
||||
context: context,
|
||||
groupBy: this.ganttData.groupedBy,
|
||||
orderBy: this.ganttData.groupedBy.map((f) => { return {name: f}; }),
|
||||
lazy: this.ganttData.groupedBy.length === 1,
|
||||
});
|
||||
}
|
||||
|
||||
const dataDef = this._rpc({
|
||||
route: '/web/dataset/search_read',
|
||||
model: this.modelName,
|
||||
fields: this._getFields(),
|
||||
context: context,
|
||||
orderBy: [{name: this.ganttData.dateStartField}],
|
||||
domain: domain,
|
||||
});
|
||||
|
||||
return this.dp.add(Promise.all([groupsDef, dataDef])).then((results) => {
|
||||
const groups = results[0] || [];
|
||||
groups.forEach((g) => (g.fromServer = true));
|
||||
const searchReadResult = results[1];
|
||||
const oldRows = this.allRows;
|
||||
this.allRows = {};
|
||||
this.ganttData.records = this._parseServerData(searchReadResult.records);
|
||||
this.ganttData.rows = this._generateRows({
|
||||
groupedBy: this.ganttData.groupedBy,
|
||||
groups: groups,
|
||||
oldRows: oldRows,
|
||||
parentPath: [],
|
||||
records: this.ganttData.records,
|
||||
});
|
||||
const proms = [];
|
||||
if (this.displayUnavailability && !this.isSampleModel) {
|
||||
proms.push(this._fetchUnavailability());
|
||||
}
|
||||
if (this.progressBarFields && !this.isSampleModel) {
|
||||
proms.push(this._fetchProgressBarData());
|
||||
}
|
||||
return Promise.all(proms);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Compute rows for unavailability rpc call.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} rows in the format of ganttData.rows
|
||||
* @returns {Object} simplified rows only containing useful attributes
|
||||
*/
|
||||
_computeUnavailabilityRows(rows) {
|
||||
return _.map(rows, (r) => {
|
||||
if (r) {
|
||||
return {
|
||||
groupedBy: r.groupedBy,
|
||||
records: r.records,
|
||||
name: r.name,
|
||||
resId: r.resId,
|
||||
rows: this._computeUnavailabilityRows(r.rows)
|
||||
}
|
||||
} else {
|
||||
return r;
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Fetches gantt unavailability.
|
||||
*
|
||||
* @private
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
_fetchUnavailability() {
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'gantt_unavailability',
|
||||
args: [
|
||||
this.convertToServerTime(this.ganttData.startDate),
|
||||
this.convertToServerTime(this.ganttData.stopDate),
|
||||
this.ganttData.scale,
|
||||
this.ganttData.groupedBy,
|
||||
this._computeUnavailabilityRows(this.ganttData.rows),
|
||||
],
|
||||
context: this.context,
|
||||
}).then((enrichedRows) => {
|
||||
// Update ganttData.rows with the new unavailabilities data
|
||||
this._updateUnavailabilityRows(this.ganttData.rows, enrichedRows);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Update rows with unavailabilities from enriched rows.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} original rows in the format of ganttData.rows
|
||||
* @param {Object} enriched rows as returned by the gantt_unavailability rpc call
|
||||
* @returns {Object} original rows enriched with the unavailabilities data
|
||||
*/
|
||||
_updateUnavailabilityRows(original, enriched) {
|
||||
_.zip(original, enriched).forEach((rowPair) => {
|
||||
const o = rowPair[0];
|
||||
const e = rowPair[1];
|
||||
o.unavailabilities = _.map(e.unavailabilities, (u) => {
|
||||
// These are new data from the server, they haven't been parsed yet
|
||||
u.start = this._parseServerValue({ type: 'datetime' }, u.start);
|
||||
u.stop = this._parseServerValue({ type: 'datetime' }, u.stop);
|
||||
return u;
|
||||
});
|
||||
if (o.rows && e.rows) {
|
||||
this._updateUnavailabilityRows(o.rows, e.rows);
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Process groups and records to generate a recursive structure according
|
||||
* to groupedBy fields. Note that there might be empty groups (filled by
|
||||
* read_goup with group_expand) that also need to be processed.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {Object[]} params.groups
|
||||
* @param {Object[]} params.records
|
||||
* @param {string[]} params.groupedBy
|
||||
* @param {Object} params.oldRows previous version of this.allRows (prior to
|
||||
* this reload), used to keep collapsed rows collapsed
|
||||
* @param {Object[]} params.parentPath used to determine the ancestors of a
|
||||
* row through their groupedBy field and value.
|
||||
* The stringification of this must give a unique identifier to the parent row.
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_generateRows(params) {
|
||||
const { groupedBy, groups, oldRows, parentPath, records } = params;
|
||||
const groupLevel = this.ganttData.groupedBy.length - groupedBy.length;
|
||||
if (!groupedBy.length || !groups.length) {
|
||||
const row = {
|
||||
groupLevel,
|
||||
id: JSON.stringify([...parentPath, {}]),
|
||||
isGroup: false,
|
||||
name: "",
|
||||
records,
|
||||
};
|
||||
this.allRows[row.id] = row;
|
||||
return [row];
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
// Some groups might be empty (thanks to expand_groups), so we can't
|
||||
// simply group the data, we need to keep all returned groups
|
||||
const groupedByField = groupedBy[0];
|
||||
const currentLevelGroups = groupBy(groups, group => {
|
||||
if (group[groupedByField] === undefined) {
|
||||
// Here we change undefined value to false as:
|
||||
// 1/ we want to group together:
|
||||
// - groups having an undefined value for groupedByField
|
||||
// - groups having false value for groupedByField
|
||||
// 2/ we want to be sure that stringification keeps
|
||||
// the groupedByField because of:
|
||||
// JSON.stringify({ key: undefined }) === "{}"
|
||||
// (see id construction below)
|
||||
group[groupedByField] = false;
|
||||
}
|
||||
return group[groupedByField];
|
||||
});
|
||||
const isM2MGrouped = this.ganttData.fields[groupedByField].type === "many2many";
|
||||
let groupedRecords;
|
||||
if (isM2MGrouped) {
|
||||
groupedRecords = {};
|
||||
for (const [key, currentGroup] of Object.entries(currentLevelGroups)) {
|
||||
groupedRecords[key] = [];
|
||||
const value = currentGroup[0][groupedByField];
|
||||
for (const r of records || []) {
|
||||
if (
|
||||
!value && r[groupedByField].length === 0 ||
|
||||
value && r[groupedByField].includes(value[0])
|
||||
) {
|
||||
groupedRecords[key].push(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groupedRecords = groupBy(records || [], groupedByField);
|
||||
}
|
||||
|
||||
for (const key in currentLevelGroups) {
|
||||
const subGroups = currentLevelGroups[key];
|
||||
const groupRecords = groupedRecords[key] || [];
|
||||
// For empty groups (or when groupedByField is a m2m), we can't look at the record to get the
|
||||
// formatted value of the field, we have to trust expand_groups.
|
||||
let value;
|
||||
if (groupRecords && groupRecords.length && !isM2MGrouped) {
|
||||
value = groupRecords[0][groupedByField];
|
||||
} else {
|
||||
value = subGroups[0][groupedByField];
|
||||
}
|
||||
const part = {};
|
||||
part[groupedByField] = value;
|
||||
const path = [...parentPath, part];
|
||||
const id = JSON.stringify(path);
|
||||
const resId = Array.isArray(value) ? value[0] : value;
|
||||
const minNbGroups = this.collapseFirstLevel ? 0 : 1;
|
||||
const isGroup = groupedBy.length > minNbGroups;
|
||||
const fromServer = subGroups.some((g) => g.fromServer);
|
||||
const row = {
|
||||
name: this._getRowName(groupedByField, value),
|
||||
groupedBy,
|
||||
groupedByField,
|
||||
groupLevel,
|
||||
id,
|
||||
resId,
|
||||
isGroup,
|
||||
fromServer,
|
||||
isOpen: !findWhere(oldRows, { id: JSON.stringify(parentPath), isOpen: false }),
|
||||
records: groupRecords,
|
||||
};
|
||||
|
||||
if (isGroup) {
|
||||
row.rows = this._generateRows({
|
||||
...params,
|
||||
groupedBy: groupedBy.slice(1),
|
||||
groups: subGroups,
|
||||
oldRows,
|
||||
parentPath: path,
|
||||
records: groupRecords,
|
||||
});
|
||||
row.childrenRowIds = [];
|
||||
row.rows.forEach((subRow) => {
|
||||
row.childrenRowIds.push(subRow.id);
|
||||
row.childrenRowIds = row.childrenRowIds.concat(subRow.childrenRowIds || []);
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
this.allRows[row.id] = row;
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
/**
|
||||
* Get domain of records to display in the gantt view.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getDomain() {
|
||||
const domain = [
|
||||
[this.ganttData.dateStartField, '<=', this.convertToServerTime(this.ganttData.stopDate)],
|
||||
[this.ganttData.dateStopField, '>=', this.convertToServerTime(this.ganttData.startDate)],
|
||||
];
|
||||
return this.domain.concat(domain);
|
||||
},
|
||||
/**
|
||||
* Get all the fields needed.
|
||||
*
|
||||
* @private
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_getFields() {
|
||||
let fields = ['display_name', this.ganttData.dateStartField, this.ganttData.dateStopField];
|
||||
fields = fields.concat(this.ganttData.groupedBy, this.decorationFields);
|
||||
|
||||
if (this.progressField) {
|
||||
fields.push(this.progressField);
|
||||
}
|
||||
|
||||
if (this.colorField) {
|
||||
fields.push(this.colorField);
|
||||
}
|
||||
|
||||
if (this.consolidationParams.field) {
|
||||
fields.push(this.consolidationParams.field);
|
||||
}
|
||||
|
||||
if (this.consolidationParams.excludeField) {
|
||||
fields.push(this.consolidationParams.excludeField);
|
||||
}
|
||||
|
||||
return _.uniq(fields);
|
||||
},
|
||||
/**
|
||||
* Format field value to display purpose.
|
||||
*
|
||||
* @private
|
||||
* @param {any} value
|
||||
* @param {Object} field
|
||||
* @returns {string} formatted field value
|
||||
*/
|
||||
_getFieldFormattedValue(value, field) {
|
||||
let options = {};
|
||||
if (field.type === 'boolean') {
|
||||
options = {forceString: true};
|
||||
}
|
||||
let label;
|
||||
if (field.type === "many2many") {
|
||||
label = Array.isArray(value) ? value[1] : value;
|
||||
} else {
|
||||
label = fieldUtils.format[field.type](value, field, options);
|
||||
}
|
||||
return label || _.str.sprintf(_t('Undefined %s'), field.string);
|
||||
},
|
||||
/**
|
||||
* @param {string} groupedByField
|
||||
* @param {*} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_getRowName(groupedByField, value) {
|
||||
const field = this.fields[groupedByField];
|
||||
return this._getFieldFormattedValue(value, field);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_isEmpty() {
|
||||
return !this.ganttData.records.length;
|
||||
},
|
||||
/**
|
||||
* Parse in place the server values (and in particular, convert datetime
|
||||
* field values to moment in UTC).
|
||||
*
|
||||
* @private
|
||||
* @param {Object} data the server data to parse
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
_parseServerData(data) {
|
||||
data.forEach((record) => {
|
||||
Object.keys(record).forEach((fieldName) => {
|
||||
record[fieldName] = this._parseServerValue(this.fields[fieldName], record[fieldName]);
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
/**
|
||||
* Set date range to render gantt
|
||||
*
|
||||
* @private
|
||||
* @param {Moment} focusDate current activated date
|
||||
* @param {string} scale current activated scale
|
||||
*/
|
||||
_setRange(focusDate, scale) {
|
||||
this.ganttData.scale = scale;
|
||||
this.ganttData.focusDate = focusDate;
|
||||
if (this.ganttData.dynamicRange) {
|
||||
this.ganttData.startDate = focusDate.clone().startOf(this.SCALES[scale].interval);
|
||||
this.ganttData.stopDate = this.ganttData.startDate.clone().add(1, scale);
|
||||
} else {
|
||||
this.ganttData.startDate = focusDate.clone().startOf(scale);
|
||||
this.ganttData.stopDate = focusDate.clone().endOf(scale);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Remove date in groupedBy field
|
||||
*/
|
||||
_filterDateInGroupedBy(groupedBy) {
|
||||
return groupedBy.filter(
|
||||
groupedByField => {
|
||||
const fieldName = groupedByField.split(':')[0];
|
||||
return fieldName in this.fields && this.fields[fieldName].type.indexOf('date') === -1;
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
//----------------------
|
||||
// Gantt Progress Bars
|
||||
//----------------------
|
||||
/**
|
||||
* Get progress bars info in order to display progress bar in gantt title column
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_fetchProgressBarData() {
|
||||
const progressBarFields = this.progressBarFields.filter(field => this.ganttData.groupedBy.includes(field));
|
||||
if (this.isSampleModel || !progressBarFields.length) {
|
||||
return;
|
||||
}
|
||||
const resIds = {};
|
||||
let hasResIds = false;
|
||||
for (const field of progressBarFields) {
|
||||
resIds[field] = this._getProgressBarResIds(field, this.ganttData.rows);
|
||||
hasResIds = hasResIds || resIds[field].length;
|
||||
}
|
||||
if (!hasResIds) {
|
||||
return;
|
||||
}
|
||||
return this._rpc({
|
||||
model: this.modelName,
|
||||
method: 'gantt_progress_bar',
|
||||
args: [
|
||||
progressBarFields,
|
||||
resIds,
|
||||
this.convertToServerTime(this.ganttData.startDate),
|
||||
this.convertToServerTime(this.ganttData.endDate || this.ganttData.startDate.clone().add(1, this.ganttData.scale)),
|
||||
],
|
||||
}).then((progressBarInfo) => {
|
||||
for (const field of progressBarFields) {
|
||||
this._addProgressBarInfo(field, this.ganttData.rows, progressBarInfo[field]);
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Recursive function to get resIds of groups where the progress bar will be added.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_getProgressBarResIds(field, rows) {
|
||||
const resIds = [];
|
||||
for (const row of rows) {
|
||||
if (row.groupedByField === field) {
|
||||
if (row.resId !== false) {
|
||||
resIds.push(row.resId);
|
||||
}
|
||||
} else {
|
||||
resIds.push(...this._getProgressBarResIds(field, row.rows || []));
|
||||
}
|
||||
}
|
||||
return [...new Set(resIds)];
|
||||
},
|
||||
/**
|
||||
* Recursive function to add progressBar info to rows grouped by the field.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addProgressBarInfo(field, rows, progressBarInfo) {
|
||||
for (const row of rows) {
|
||||
if (row.groupedByField === field) {
|
||||
row.progressBar = progressBarInfo[row.resId];
|
||||
if (row.progressBar) {
|
||||
row.progressBar.value_formatted = fieldUtils.format.float(row.progressBar.value, {'digits': [false, 0]});
|
||||
row.progressBar.max_value_formatted = fieldUtils.format.float(row.progressBar.max_value, {'digits': [false, 0]});
|
||||
row.progressBar.ratio = row.progressBar.max_value ? row.progressBar.value / row.progressBar.max_value * 100 : 0;
|
||||
row.progressBar.warning = progressBarInfo.warning;
|
||||
}
|
||||
} else {
|
||||
this._addProgressBarInfo(field, row.rows, progressBarInfo);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
1217
web_gantt/static/src/js/gantt_renderer.js
Normal file
1217
web_gantt/static/src/js/gantt_renderer.js
Normal file
File diff suppressed because it is too large
Load Diff
1240
web_gantt/static/src/js/gantt_row.js
Normal file
1240
web_gantt/static/src/js/gantt_row.js
Normal file
File diff suppressed because it is too large
Load Diff
189
web_gantt/static/src/js/gantt_view.js
Normal file
189
web_gantt/static/src/js/gantt_view.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/** @odoo-module alias=web_gantt.GanttView */
|
||||
|
||||
import AbstractView from 'web.AbstractView';
|
||||
import core from 'web.core';
|
||||
import GanttModel from 'web_gantt.GanttModel';
|
||||
import GanttRenderer from 'web_gantt.GanttRenderer';
|
||||
import GanttController from 'web_gantt.GanttController';
|
||||
import pyUtils from 'web.py_utils';
|
||||
import view_registry from 'web.view_registry';
|
||||
|
||||
const _t = core._t;
|
||||
const _lt = core._lt;
|
||||
|
||||
const GanttView = AbstractView.extend({
|
||||
display_name: _lt('Gantt'),
|
||||
icon: 'fa fa-tasks',
|
||||
config: _.extend({}, AbstractView.prototype.config, {
|
||||
Model: GanttModel,
|
||||
Controller: GanttController,
|
||||
Renderer: GanttRenderer,
|
||||
}),
|
||||
jsLibs: [
|
||||
'/web/static/lib/nearest/jquery.nearest.js',
|
||||
],
|
||||
viewType: 'gantt',
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init(viewInfo, params) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
const { domain } = params.action || {};
|
||||
this.controllerParams.actionDomain = domain || [];
|
||||
|
||||
this.SCALES = {
|
||||
day: { string: _t('Day'), hotkey: 'e', cellPrecisions: { full: 60, half: 30, quarter: 15 }, defaultPrecision: 'full', time: 'minutes', interval: 'hour' },
|
||||
week: { string: _t('Week'), hotkey: 'p', cellPrecisions: { full: 24, half: 12 }, defaultPrecision: 'half', time: 'hours', interval: 'day' },
|
||||
month: { string: _t('Month'), hotkey: 'm', cellPrecisions: { full: 24, half: 12 }, defaultPrecision: 'half', time: 'hours', interval: 'day' },
|
||||
year: { string: _t('Year'), hotkey: 'y', cellPrecisions: { full: 1 }, defaultPrecision: 'full', time: 'months', interval: 'month' },
|
||||
};
|
||||
|
||||
const arch = this.arch;
|
||||
|
||||
// Decoration fields
|
||||
const decorationFields = [];
|
||||
_.each(arch.children, (child) => {
|
||||
if (child.tag === 'field') {
|
||||
decorationFields.push(child.attrs.name);
|
||||
}
|
||||
});
|
||||
|
||||
let collapseFirstLevel = !!arch.attrs.collapse_first_level;
|
||||
|
||||
// Unavailability
|
||||
const displayUnavailability = !!arch.attrs.display_unavailability;
|
||||
|
||||
// Colors
|
||||
const colorField = arch.attrs.color;
|
||||
|
||||
// Cell precision
|
||||
// precision = {'day': 'hour:half', 'week': 'day:half', 'month': 'day', 'year': 'month:quarter'}
|
||||
const precisionAttrs = arch.attrs.precision ? pyUtils.py_eval(arch.attrs.precision) : {};
|
||||
const cellPrecisions = {};
|
||||
_.each(this.SCALES, (vals, key) => {
|
||||
if (precisionAttrs[key]) {
|
||||
const precision = precisionAttrs[key].split(':'); // hour:half
|
||||
// Note that precision[0] (which is the cell interval) is not
|
||||
// taken into account right now because it is no customizable.
|
||||
if (precision[1] && _.contains(_.keys(vals.cellPrecisions), precision[1])) {
|
||||
cellPrecisions[key] = precision[1];
|
||||
}
|
||||
}
|
||||
cellPrecisions[key] = cellPrecisions[key] || vals.defaultPrecision;
|
||||
});
|
||||
|
||||
let consolidationMaxField;
|
||||
let consolidationMaxValue;
|
||||
const consolidationMax = arch.attrs.consolidation_max ? pyUtils.py_eval(arch.attrs.consolidation_max) : {};
|
||||
if (Object.keys(consolidationMax).length > 0) {
|
||||
consolidationMaxField = Object.keys(consolidationMax)[0];
|
||||
consolidationMaxValue = consolidationMax[consolidationMaxField];
|
||||
// We need to display the aggregates even if there is only one groupby
|
||||
collapseFirstLevel = !!consolidationMaxField || collapseFirstLevel;
|
||||
}
|
||||
|
||||
const consolidationParams = {
|
||||
field: arch.attrs.consolidation,
|
||||
maxField: consolidationMaxField,
|
||||
maxValue: consolidationMaxValue,
|
||||
excludeField: arch.attrs.consolidation_exclude,
|
||||
};
|
||||
|
||||
// form view which is opened by gantt
|
||||
let formViewId = arch.attrs.form_view_id ? parseInt(arch.attrs.form_view_id, 10) : false;
|
||||
if (params.action && !formViewId) { // fallback on form view action, or 'false'
|
||||
const result = _.findWhere(params.action.views, { type: 'form' });
|
||||
formViewId = result ? result.viewID : false;
|
||||
}
|
||||
const dialogViews = [[formViewId, 'form']];
|
||||
|
||||
let allowedScales;
|
||||
if (arch.attrs.scales) {
|
||||
const possibleScales = Object.keys(this.SCALES);
|
||||
allowedScales = _.reduce(arch.attrs.scales.split(','), (allowedScales, scale) => {
|
||||
if (possibleScales.indexOf(scale) >= 0) {
|
||||
allowedScales.push(scale.trim());
|
||||
}
|
||||
return allowedScales;
|
||||
}, []);
|
||||
} else {
|
||||
allowedScales = Object.keys(this.SCALES);
|
||||
}
|
||||
|
||||
const scale = params.context.default_scale || arch.attrs.default_scale || 'month';
|
||||
const initialDate = moment(params.context.initialDate || params.initialDate || arch.attrs.initial_date || new Date());
|
||||
const offset = arch.attrs.offset;
|
||||
if (offset && scale) {
|
||||
initialDate.add(offset, scale);
|
||||
}
|
||||
|
||||
// thumbnails for groups (display a thumbnail next to the group name)
|
||||
const thumbnails = this.arch.attrs.thumbnails ? pyUtils.py_eval(this.arch.attrs.thumbnails) : {};
|
||||
// plan option
|
||||
const canPlan = this.arch.attrs.plan ? !!JSON.parse(this.arch.attrs.plan) : true;
|
||||
// cell create option
|
||||
const canCellCreate = this.arch.attrs.cell_create ? !!JSON.parse(this.arch.attrs.cell_create) : true;
|
||||
|
||||
// Dependencies
|
||||
const dependencyField = !!this.arch.attrs.dependency_field && this.arch.attrs.dependency_field;
|
||||
const dependencyInvertedField = !!this.arch.attrs.dependency_inverted_field && this.arch.attrs.dependency_inverted_field;
|
||||
if (dependencyField) {
|
||||
decorationFields.push(dependencyField);
|
||||
}
|
||||
|
||||
this.controllerParams.context = params.context || {};
|
||||
this.controllerParams.dialogViews = dialogViews;
|
||||
this.controllerParams.SCALES = this.SCALES;
|
||||
this.controllerParams.allowedScales = allowedScales;
|
||||
this.controllerParams.collapseFirstLevel = collapseFirstLevel;
|
||||
this.controllerParams.createAction = arch.attrs.on_create || null;
|
||||
|
||||
this.loadParams.initialDate = initialDate;
|
||||
this.loadParams.collapseFirstLevel = collapseFirstLevel;
|
||||
this.loadParams.colorField = colorField;
|
||||
this.loadParams.dateStartField = arch.attrs.date_start;
|
||||
this.loadParams.dateStopField = arch.attrs.date_stop;
|
||||
this.loadParams.progressField = arch.attrs.progress;
|
||||
this.loadParams.decorationFields = decorationFields;
|
||||
this.loadParams.defaultGroupBy = this.arch.attrs.default_group_by;
|
||||
this.loadParams.permanentGroupBy = this.arch.attrs.permanent_group_by;
|
||||
this.loadParams.dynamicRange = this.arch.attrs.dynamic_range;
|
||||
this.loadParams.displayUnavailability = displayUnavailability;
|
||||
this.loadParams.fields = this.fields;
|
||||
this.loadParams.scale = scale;
|
||||
this.loadParams.SCALES = this.SCALES;
|
||||
this.loadParams.consolidationParams = consolidationParams;
|
||||
this.loadParams.progressBarFields = arch.attrs.progress_bar;
|
||||
|
||||
this.modelParams.dependencyField = dependencyField;
|
||||
this.modelParams.dependencyInvertedField = dependencyInvertedField;
|
||||
|
||||
this.rendererParams.canCreate = this.controllerParams.activeActions.create;
|
||||
this.rendererParams.canCellCreate = canCellCreate;
|
||||
this.rendererParams.canEdit = this.controllerParams.activeActions.edit;
|
||||
this.rendererParams.canPlan = canPlan && this.rendererParams.canEdit;
|
||||
this.rendererParams.fieldsInfo = viewInfo.fields;
|
||||
this.rendererParams.SCALES = this.SCALES;
|
||||
this.rendererParams.cellPrecisions = cellPrecisions;
|
||||
this.rendererParams.totalRow = arch.attrs.total_row || false;
|
||||
this.rendererParams.string = arch.attrs.string || _t('Gantt View');
|
||||
this.rendererParams.popoverTemplate = _.findWhere(arch.children, {tag: 'templates'});
|
||||
this.rendererParams.colorField = colorField;
|
||||
this.rendererParams.disableDragdrop = arch.attrs.disable_drag_drop ? !!JSON.parse(arch.attrs.disable_drag_drop) : false;
|
||||
this.rendererParams.progressField = arch.attrs.progress;
|
||||
this.rendererParams.displayUnavailability = displayUnavailability;
|
||||
this.rendererParams.collapseFirstLevel = collapseFirstLevel;
|
||||
this.rendererParams.consolidationParams = consolidationParams;
|
||||
this.rendererParams.thumbnails = thumbnails;
|
||||
this.rendererParams.progressBarFields = arch.attrs.progress_bar;
|
||||
this.rendererParams.pillLabel = !!arch.attrs.pill_label;
|
||||
this.rendererParams.dependencyEnabled = !!this.modelParams.dependencyField
|
||||
this.rendererParams.dependencyField = this.modelParams.dependencyField
|
||||
},
|
||||
});
|
||||
|
||||
view_registry.add('gantt', GanttView);
|
||||
|
||||
export default GanttView;
|
||||
Reference in New Issue
Block a user