1220 lines
46 KiB
JavaScript
1220 lines
46 KiB
JavaScript
/** @odoo-module alias=web_gantt.GanttRenderer */
|
|
|
|
import AbstractRenderer from 'web.AbstractRenderer';
|
|
import core from 'web.core';
|
|
import GanttRow from 'web_gantt.GanttRow';
|
|
import qweb from 'web.QWeb';
|
|
import session from 'web.session';
|
|
import utils from 'web.utils';
|
|
import ConnectorContainer from './connector/connector_container';
|
|
import { device, isDebug } from 'web.config';
|
|
import { ComponentWrapper, WidgetAdapterMixin } from 'web.OwlCompatibility';
|
|
|
|
const QWeb = core.qweb;
|
|
const _t = core._t;
|
|
|
|
export default AbstractRenderer.extend(WidgetAdapterMixin, {
|
|
config: {
|
|
GanttRow: GanttRow
|
|
},
|
|
custom_events: _.extend({}, AbstractRenderer.prototype.custom_events, {
|
|
start_dragging: '_onStartDragging',
|
|
start_no_dragging: '_onStartNoDragging',
|
|
stop_dragging: '_onStopDragging',
|
|
stop_no_dragging: '_onStopNoDragging',
|
|
}),
|
|
|
|
DECORATIONS: [
|
|
'decoration-secondary',
|
|
'decoration-success',
|
|
'decoration-info',
|
|
'decoration-warning',
|
|
'decoration-danger',
|
|
],
|
|
sampleDataTargets: [
|
|
'.o_gantt_row',
|
|
],
|
|
/**
|
|
* @override
|
|
* @param {Widget} parent
|
|
* @param {Object} state
|
|
* @param {Object} params
|
|
* @param {boolean} params.canCreate
|
|
* @param {boolean} params.canEdit
|
|
* @param {boolean} params.canCellCreate
|
|
* @param {Object} params.cellPrecisions
|
|
* @param {string} params.colorField
|
|
* @param {Object} params.fieldsInfo
|
|
* @param {Object} params.SCALES
|
|
* @param {string} params.string
|
|
* @param {string} params.totalRow
|
|
* @param {string} [params.popoverTemplate]
|
|
*/
|
|
init(parent, state, params) {
|
|
this._super.apply(this, arguments);
|
|
|
|
this.$draggedPill = null;
|
|
this.$draggedPillClone = null;
|
|
|
|
this.canCreate = params.canCreate;
|
|
this.canCellCreate = params.canCellCreate;
|
|
this.canEdit = params.canEdit;
|
|
this.canPlan = params.canPlan;
|
|
this.cellPrecisions = params.cellPrecisions;
|
|
this.colorField = params.colorField;
|
|
this.disableDragdrop = params.disableDragdrop;
|
|
this.progressField = params.progressField;
|
|
this.consolidationParams = params.consolidationParams;
|
|
this.fieldsInfo = params.fieldsInfo;
|
|
this.SCALES = params.SCALES;
|
|
this.string = params.string;
|
|
this.totalRow = params.totalRow;
|
|
this.collapseFirstLevel = params.collapseFirstLevel;
|
|
this.thumbnails = params.thumbnails;
|
|
this.dependencyEnabled = params.dependencyEnabled;
|
|
this.pillLabel = params.pillLabel;
|
|
this.dependencyField = params.dependencyField
|
|
|
|
this.rowWidgets = {};
|
|
// Pill decoration colors, By default display primary color for pill
|
|
this.pillDecorations = _.chain(this.arch.attrs)
|
|
.pick((value, key) => {
|
|
return this.DECORATIONS.indexOf(key) >= 0;
|
|
}).mapObject((value) => {
|
|
return py.parse(py.tokenize(value));
|
|
}).value();
|
|
if (params.popoverTemplate) {
|
|
this.popoverQWeb = new qweb(isDebug(), {_s: session.origin});
|
|
this.popoverQWeb.add_template(utils.json_node_to_xml(params.popoverTemplate));
|
|
} else {
|
|
this.popoverQWeb = QWeb;
|
|
}
|
|
|
|
this.isRTL = _t.database.parameters.direction === "rtl";
|
|
this.template_to_use = "GanttView";
|
|
this.firstRendering = true;
|
|
|
|
if (this.dependencyEnabled) {
|
|
this._initialize_connectors();
|
|
this._preventHoverEffect = false;
|
|
this._connectorsStrokeColors = this._getStrokeColors();
|
|
this._connectorsStrokeWarningColors = this._getStrokeWarningColors();
|
|
this._connectorsStrokeErrorColors = this._getStrokeErrorColors();
|
|
this._connectorsOutlineStrokeColor = this._getOutlineStrokeColors();
|
|
this._connectorsCssSelectors = {
|
|
bullet: '.o_connector_creator_bullet',
|
|
pill: '.o_gantt_pill',
|
|
pillWrapper: '.o_gantt_pill_wrapper',
|
|
wrapper: '.o_connector_creator_wrapper',
|
|
groupByNoGroup: '.o_gantt_row_nogroup',
|
|
};
|
|
this.events = Object.assign({ }, this.events, {
|
|
'mouseenter .o_gantt_pill, .o_connector_creator_wrapper': '_onPillMouseEnter',
|
|
'mouseleave .o_gantt_pill, .o_connector_creator_wrapper': '_onPillMouseLeave',
|
|
});
|
|
}
|
|
},
|
|
/**
|
|
* Called each time the renderer is attached into the DOM.
|
|
*/
|
|
on_attach_callback() {
|
|
this._isInDom = true;
|
|
core.bus.on("keydown", this, this._onKeydown);
|
|
core.bus.on("keyup", this, this._onKeyup);
|
|
if (!this.disableDragdrop) {
|
|
this._setRowsDroppable();
|
|
}
|
|
if (this.dependencyEnabled) {
|
|
WidgetAdapterMixin.on_attach_callback.call(this);
|
|
// As we need the source and target of the connectors to be part of the dom,
|
|
// we need to use the on_attach_callback in order to have the first rendering successful.
|
|
this._mountConnectorContainer();
|
|
window.addEventListener('resize', this._throttledReRender);
|
|
}
|
|
},
|
|
/**
|
|
* Called each time the renderer is detached from the DOM.
|
|
*/
|
|
on_detach_callback() {
|
|
this._isInDom = false;
|
|
core.bus.off("keydown", this, this._onKeydown);
|
|
core.bus.off("keyup", this, this._onKeyup);
|
|
_.invoke(this.rowWidgets, 'on_detach_callback');
|
|
if (this.dependencyEnabled) {
|
|
WidgetAdapterMixin.on_detach_callback.call(this);
|
|
this._connectorContainerComponent.unmount();
|
|
}
|
|
},
|
|
/**
|
|
* @override
|
|
*/
|
|
destroy() {
|
|
this._super(...arguments);
|
|
if (this.dependencyEnabled) {
|
|
window.removeEventListener('resize', this._throttledReRender);
|
|
}
|
|
},
|
|
/**
|
|
* @override
|
|
*/
|
|
async start() {
|
|
await this._super(...arguments);
|
|
if (this.dependencyEnabled) {
|
|
this._connectorContainerComponent = new ComponentWrapper(this, ConnectorContainer, this._getConnectorContainerProps());
|
|
this._throttledReRender = _.throttle(async () => {
|
|
await this.updateConnectorContainerComponent();
|
|
}, 100);
|
|
}
|
|
},
|
|
/**
|
|
* Make sure the connectorManager Component is updated each time the view is updated.
|
|
*
|
|
* @override
|
|
*/
|
|
async update() {
|
|
if (this.dependencyEnabled) {
|
|
await this.updateConnectorContainerComponent();
|
|
}
|
|
await this._super(...arguments);
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Sets the class on the gantt_view corresponding to the mode.
|
|
* This class is used to prevent the magnifier and + buttons during connection creation.
|
|
*
|
|
* @param {boolean} in_creation
|
|
*/
|
|
set_connector_creation_mode(in_creation) {
|
|
this.el.classList.toggle('o_grabbing', in_creation);
|
|
},
|
|
/**
|
|
* Toggles the highlighting of the connector.
|
|
*
|
|
* @param {ConnectorContainer.Connector.props} connector
|
|
* @param {boolean} highlighted
|
|
*/
|
|
toggleConnectorHighlighting(connector, highlighted) {
|
|
const masterPill = this._rowsAndRecordsDict.rows[connector.data.masterRowId].records[connector.data.masterId].pillElement;
|
|
const slavePill = this._rowsAndRecordsDict.rows[connector.data.slaveRowId].records[connector.data.slaveId].pillElement;
|
|
const sourceConnectorCreatorInfo = this._getConnectorCreatorInfo(masterPill);
|
|
const targetConnectorCreatorInfo = this._getConnectorCreatorInfo(slavePill);
|
|
if (!this._isConnectorCreatorDragged(sourceConnectorCreatorInfo)) {
|
|
sourceConnectorCreatorInfo.pill.classList.toggle('highlight', highlighted);
|
|
}
|
|
if (!this._isConnectorCreatorDragged(targetConnectorCreatorInfo)) {
|
|
targetConnectorCreatorInfo.pill.classList.toggle('highlight', highlighted);
|
|
}
|
|
},
|
|
/**
|
|
* Toggles the preventConnectorsHover props of the connector container.
|
|
*
|
|
* @param {boolean} prevent
|
|
*/
|
|
togglePreventConnectorsHoverEffect(prevent){
|
|
this._preventHoverEffect = prevent;
|
|
if (this.dependencyEnabled && this._shouldRenderConnectors()) {
|
|
this._connectorContainerComponent.update(this._getConnectorContainerProps());
|
|
}
|
|
},
|
|
/**
|
|
* Toggles the highlighting of the pill and connector creator of the provided element.
|
|
*
|
|
* @param {HTMLElement} element
|
|
* @param {boolean} highlighted
|
|
*/
|
|
async togglePillHighlighting(element, highlighted) {
|
|
const connectorCreatorInfo = this._getConnectorCreatorInfo(element);
|
|
if (connectorCreatorInfo.pill.dataset.id != 0) {
|
|
const connectedConnectors = Object.values(this._connectors)
|
|
.filter((connector) => {
|
|
const ids = [connector.data.slaveId, connector.data.masterId];
|
|
return ids.includes(
|
|
parseInt(connectorCreatorInfo.pill.dataset.id)
|
|
);
|
|
});
|
|
if (connectedConnectors.length) {
|
|
connectedConnectors.forEach((connector) => {
|
|
connector.hovered = highlighted;
|
|
connector.canBeRemoved = !highlighted;
|
|
});
|
|
await this._connectorContainerComponent.update(this._getConnectorContainerProps());
|
|
}
|
|
|
|
if (!(this._rowsAndRecordsDict
|
|
&& this._rowsAndRecordsDict.records[connectorCreatorInfo.pill.dataset.id]
|
|
&& this._rowsAndRecordsDict.records[connectorCreatorInfo.pill.dataset.id].rowsInfo)) return;
|
|
|
|
for (const pill of Object.values(this._rowsAndRecordsDict.records[connectorCreatorInfo.pill.dataset.id].rowsInfo).map((rowInfo) => rowInfo.pillElement)) {
|
|
const tempConnectorCreatorInfo = this._getConnectorCreatorInfo(pill);
|
|
if (highlighted || !this._isConnectorCreatorDragged(tempConnectorCreatorInfo)) {
|
|
tempConnectorCreatorInfo.pill.classList.toggle('highlight', highlighted);
|
|
if (connectorCreatorInfo.pill === tempConnectorCreatorInfo.pill) {
|
|
for (const connectorCreator of tempConnectorCreatorInfo.connectorCreators) {
|
|
connectorCreator.classList.toggle('invisible', !highlighted);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Re-render a given row and its sub-rows. This typically occurs when a row
|
|
* is collapsed/expanded, to prevent from re-rendering the whole view.
|
|
*
|
|
* @param {Object} rowState part of the state concerning the row to update
|
|
* @returns {Promise}
|
|
*/
|
|
updateRow(rowState) {
|
|
const oldRowIds = [rowState.id].concat(rowState.childrenRowIds);
|
|
const oldRows = [];
|
|
oldRowIds.forEach((rowId) => {
|
|
if (this.rowWidgets[rowId]) {
|
|
oldRows.push(this.rowWidgets[rowId]);
|
|
delete this.rowWidgets[rowId];
|
|
}
|
|
});
|
|
this.proms = [];
|
|
const rows = this._renderRows([rowState], rowState.groupedBy);
|
|
const proms = this.proms;
|
|
delete this.proms;
|
|
return Promise.all(proms).then(() => {
|
|
let $previousRow = oldRows[0].$el;
|
|
rows.forEach((row) => {
|
|
row.$el.insertAfter($previousRow);
|
|
$previousRow = row.$el;
|
|
});
|
|
_.invoke(oldRows, 'destroy');
|
|
if (!this.disableDragdrop) {
|
|
this._setRowsDroppable();
|
|
}
|
|
if (this.dependencyEnabled && this._shouldRenderConnectors()) {
|
|
this.updateConnectorContainerComponent();
|
|
}
|
|
});
|
|
},
|
|
/**
|
|
* Update the ConnectorContainer component with updated connectors.
|
|
* @returns {Promise}
|
|
*/
|
|
async updateConnectorContainerComponent() {
|
|
await this._connectorContainerComponent.update(this._generateAndGetConnectorContainerProps());
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Applies the style to the connector depending on the gantt date start and stop values.
|
|
*
|
|
* @param {Object} connector
|
|
* @param {Object} masterRecord the record the slaveRecord depends on.
|
|
* @param {Object} slaveRecord the record that depends on the masterRecord.
|
|
* @private
|
|
*/
|
|
_applySpecialColors(connector, masterRecord, slaveRecord) {
|
|
let specialColors;
|
|
if (slaveRecord[this.state.dateStartField].isBefore(masterRecord[this.state.dateStopField])) {
|
|
specialColors = this._connectorsStrokeWarningColors;
|
|
if (slaveRecord[this.state.dateStartField].isBefore(masterRecord[this.state.dateStartField])) {
|
|
specialColors = this._connectorsStrokeErrorColors;
|
|
}
|
|
}
|
|
if (specialColors) {
|
|
connector['style'] = {
|
|
stroke: {
|
|
color: specialColors.stroke,
|
|
hoveredColor: specialColors.hoveredStroke,
|
|
}
|
|
};
|
|
}
|
|
},
|
|
/**
|
|
* Updates the connectors state in regards to the records state and returns the props.
|
|
*
|
|
* @return {Object} the props to pass to the ConnectorContainer
|
|
* @private
|
|
*/
|
|
_generateAndGetConnectorContainerProps() {
|
|
this._preventHoverEffect = false;
|
|
this._initialize_connectors();
|
|
if (this._shouldRenderConnectors()) {
|
|
this._generateConnectors();
|
|
}
|
|
return this._getConnectorContainerProps();
|
|
},
|
|
/**
|
|
* Updates the connectors state according to the records state.
|
|
*
|
|
* @private
|
|
*/
|
|
_generateConnectors() {
|
|
/*
|
|
First we need to build a dictionary in order to be able to manage the cases when a record is present
|
|
multiple times in the gantt view, in order to draw the connectors accordingly.
|
|
Structure of dict:
|
|
{
|
|
records : {
|
|
#ID_RECORD_1: {
|
|
record: STATE_RECORD,
|
|
rowsInfo: {
|
|
#ID_ROW_1: {
|
|
pillElement: HTMLElementPill1,
|
|
},
|
|
...
|
|
}
|
|
},
|
|
...
|
|
},
|
|
rows: {
|
|
#ID_ROW_1: {
|
|
records: {
|
|
#ID_RECORD_1: {
|
|
pillElement: HTMLElementPill1,
|
|
record: STATE_RECORD
|
|
},
|
|
...
|
|
}
|
|
},
|
|
...
|
|
},
|
|
}
|
|
*/
|
|
this._rowsAndRecordsDict = {
|
|
records: { },
|
|
rows: { },
|
|
};
|
|
for (const row of this.state.rows) {
|
|
// We need to remove the closing "}]" from the row.id in order to ensure that things works
|
|
// smoothly when collapse_first_level option is activated. Then we need to escape '"' &
|
|
// '\' from the row.id before calling the querySelector.
|
|
const rowElementSelector = `${this._connectorsCssSelectors.groupByNoGroup}[data-row-id^="${row.id.replace("}]", "").replace(/["\\]/g, '\\$&')}"]`;
|
|
const rowElement = this.el.querySelector(rowElementSelector);
|
|
if (!rowElement) continue;
|
|
this._rowsAndRecordsDict.rows[row.id] = {
|
|
records: { }
|
|
};
|
|
for (const record of row.records) {
|
|
if (!this._shouldRenderRecordConnectors(record)) {
|
|
continue;
|
|
}
|
|
const recordElementSelector = `${this._connectorsCssSelectors.pill}[data-id="${record.id}"]`;
|
|
const pillElement = rowElement.querySelector(recordElementSelector);
|
|
this._rowsAndRecordsDict.rows[row.id].records[record.id] = {
|
|
pillElement: pillElement,
|
|
record: record,
|
|
};
|
|
if (!(record.id in this._rowsAndRecordsDict.records)) {
|
|
this._rowsAndRecordsDict.records[record.id] = {
|
|
record: record,
|
|
rowsInfo: { },
|
|
};
|
|
}
|
|
this._rowsAndRecordsDict.records[record.id].rowsInfo[row.id] = {
|
|
pillElement: pillElement,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Then we go over the rows and records one by one in order to create the connectors
|
|
const connector_id_generator = {
|
|
_value: 1,
|
|
getNext() {
|
|
return this._value++;
|
|
}
|
|
};
|
|
for (const record of this.state.records) {
|
|
const connectors = this._generateConnectorsForRecord(record, connector_id_generator);
|
|
Object.assign(this._connectors, connectors);
|
|
}
|
|
},
|
|
/**
|
|
* Generates the connectors using the dependencyField of the provided slave record.
|
|
*
|
|
* @param {Object} slaveRecord the slave record.
|
|
* @param {{ getNext(): Number }} connector_id_generator a connector_id generator.
|
|
* @private
|
|
*/
|
|
_generateConnectorsForRecord(slaveRecord, connector_id_generator) {
|
|
const result = {};
|
|
for (const masterId of slaveRecord[this.dependencyField]) {
|
|
if (masterId in this._rowsAndRecordsDict.records) {
|
|
let connectors = [];
|
|
if (!this._rowsAndRecordsDict.records[slaveRecord.id]) continue;
|
|
for (const slaveRowId in this._rowsAndRecordsDict.records[slaveRecord.id].rowsInfo) {
|
|
if (!this._rowsAndRecordsDict.records[masterId]) continue;
|
|
for (const masterRowId in this._rowsAndRecordsDict.records[masterId].rowsInfo) {
|
|
/**
|
|
* Having:
|
|
* * B dependent on A
|
|
* * C dependent on B
|
|
* * D dependent on C
|
|
* Prevent:
|
|
* * Connectors between B & C that are not in the same group if B is in same group than C:
|
|
* G1 B --- C B --- C
|
|
* / \ / \ / \
|
|
* G2 A D => A D
|
|
* \ / \ / \ /
|
|
* G3 B --- C B --- C
|
|
* * Connectors between A & B if A has already a link to B in the same group:
|
|
* G1 --------- B --------- B
|
|
* / / /
|
|
* G2 A / => A
|
|
* /
|
|
* G3 A ----------- B A ----------- B
|
|
* Allow:
|
|
* * Connectors between C & B when A & B are always present in the same groups
|
|
* G1 A ------ B A ------ B
|
|
* /
|
|
* G2 A => A ====
|
|
* \
|
|
* G3 A ------ B A ------ B
|
|
*/
|
|
if (masterRowId === slaveRowId
|
|
|| !(
|
|
slaveRecord.id in this._rowsAndRecordsDict.rows[masterRowId].records
|
|
|| masterId in this._rowsAndRecordsDict.rows[slaveRowId].records
|
|
)
|
|
|| Object.keys(this._rowsAndRecordsDict.records[slaveRecord.id].rowsInfo).every(
|
|
(rowId) => (masterRowId !== rowId && masterId in this._rowsAndRecordsDict.rows[rowId].records)
|
|
)
|
|
|| Object.keys(this._rowsAndRecordsDict.records[masterId].rowsInfo).every(
|
|
(rowId) => (slaveRowId !== rowId && slaveRecord.id in this._rowsAndRecordsDict.rows[rowId].records)
|
|
)
|
|
) {
|
|
connectors.push(
|
|
this._generateConnector(
|
|
masterRowId,
|
|
this._rowsAndRecordsDict.records[masterId].record,
|
|
slaveRowId,
|
|
slaveRecord,
|
|
connector_id_generator)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
for (const connector of connectors) {
|
|
result[connector.id] = connector;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
/**
|
|
*
|
|
* @param Number masterRowId the row id of the masterRecord (in order to handle m2m grouping)
|
|
* @param {Object} masterRecord the record the slaveRecord depends on.
|
|
* @param Number slaveRowId the row id of the slave record (in order to handle m2m grouping)
|
|
* @param {Object} slaveRecord the record that depends on the masterRecord.
|
|
* @param {{ getNext(): Number }} connector_id_generator a connector_id generator.
|
|
* @return {Object} a connector for the provided parameters.
|
|
* @private
|
|
*/
|
|
_generateConnector(masterRowId, masterRecord, slaveRowId, slaveRecord, connector_id_generator) {
|
|
const masterRecordPill = this._rowsAndRecordsDict.rows[masterRowId].records[masterRecord.id].pillElement;
|
|
const slaveRecordPill = this._rowsAndRecordsDict.rows[slaveRowId].records[slaveRecord.id].pillElement;
|
|
let source = this._connectorContainerComponent.componentRef.comp.getAnchorsPositions(masterRecordPill);
|
|
let target = this._connectorContainerComponent.componentRef.comp.getAnchorsPositions(slaveRecordPill);
|
|
|
|
const connector = {
|
|
id: connector_id_generator.getNext(),
|
|
source: source.right,
|
|
canBeRemoved: true,
|
|
data: {
|
|
slaveId: slaveRecord.id,
|
|
slaveRowId: slaveRowId,
|
|
masterId: masterRecord.id,
|
|
masterRowId: masterRowId,
|
|
},
|
|
target: target.left,
|
|
};
|
|
|
|
this._applySpecialColors(connector, masterRecord, slaveRecord)
|
|
|
|
return connector;
|
|
},
|
|
/**
|
|
* Determines if a dragged pill aims to be copied or updated
|
|
* @private
|
|
* @param {jQueryEvent} event
|
|
*/
|
|
_getAction(event) {
|
|
return event.ctrlKey || event.metaKey ? 'copy': 'reschedule';
|
|
},
|
|
/**
|
|
* Gets the connector creator info for the provided element.
|
|
*
|
|
* @param {HTMLElement} element HTMLElement with a class of either o_connector_creator_bullet,
|
|
* o_connector_creator_wrapper, o_gantt_pill or o_gantt_pill_wrapper.
|
|
* @returns {{pillWrapper: HTMLElement, pill: HTMLElement, connectorCreators: Array<HTMLElement>}}
|
|
* @private
|
|
*/
|
|
_getConnectorCreatorInfo(element) {
|
|
let connectorCreators = [];
|
|
let pill = null;
|
|
if (element.matches(this._connectorsCssSelectors.pillWrapper)) {
|
|
element = element.querySelector(this._connectorsCssSelectors.pill);
|
|
}
|
|
if (element.matches(this._connectorsCssSelectors.bullet)) {
|
|
element = element.closest(this._connectorsCssSelectors.wrapper);
|
|
}
|
|
if (element.matches(this._connectorsCssSelectors.pill)) {
|
|
pill = element;
|
|
connectorCreators = Array.from(element.parentElement.querySelectorAll(this._connectorsCssSelectors.wrapper));
|
|
} else if (element.matches(this._connectorsCssSelectors.wrapper)) {
|
|
connectorCreators = [element];
|
|
pill = element.parentElement.querySelector(this._connectorsCssSelectors.pill);
|
|
}
|
|
return {
|
|
pill: pill,
|
|
pillWrapper: pill.parentElement,
|
|
connectorCreators: connectorCreators,
|
|
};
|
|
},
|
|
/**
|
|
* Returns the props according to the current connectors state
|
|
*
|
|
* @returns {Object} the props to pass to the ConnectorContainer.
|
|
* @private
|
|
*/
|
|
_getConnectorContainerProps() {
|
|
return {
|
|
connectors: this._connectors,
|
|
defaultStyle: {
|
|
slackness: 0.9,
|
|
stroke: {
|
|
color: this._connectorsStrokeColors.stroke,
|
|
hoveredColor: this._connectorsStrokeColors.hoveredStroke,
|
|
width: 2,
|
|
},
|
|
outlineStroke: {
|
|
color: this._connectorsOutlineStrokeColor.stroke,
|
|
hoveredColor: this._connectorsOutlineStrokeColor.hoveredStroke,
|
|
width: 1,
|
|
}
|
|
},
|
|
hoverEaseWidth: 10,
|
|
preventHoverEffect: this._preventHoverEffect,
|
|
sourceQuerySelector: this._connectorsCssSelectors.bullet,
|
|
targetQuerySelector: this._connectorsCssSelectors.pillWrapper,
|
|
onCreationAbort: this._onConnectorCreationAbort.bind(this),
|
|
onCreationDone: this._onConnectorCreationDone.bind(this),
|
|
onCreationStart: this._onConnectorCreationStart.bind(this),
|
|
onMouseOut: this._onConnectorMouseOut.bind(this),
|
|
onMouseOver: this._onConnectorMouseOver.bind(this),
|
|
onRemoveButtonClick: this._onConnectorRemoveButtonClick.bind(this),
|
|
onRescheduleLaterButtonClick: this._onConnectorRescheduleLaterButtonClick.bind(this),
|
|
onRescheduleSoonerButtonClick: this._onConnectorRescheduleSoonerButtonClick.bind(this),
|
|
};
|
|
|
|
},
|
|
/**
|
|
* Gets the rgba css string corresponding to the provided parameters.
|
|
*
|
|
* @param {number} r - [0, 255]
|
|
* @param {number} g - [0, 255]
|
|
* @param {number} b - [0, 255]
|
|
* @param {number} [a = 1] - [0, 1]
|
|
* @return {string} the css color.
|
|
* @private
|
|
*/
|
|
_getCssRGBAColor(r, g, b, a) {
|
|
return `rgba(${ r }, ${ g }, ${ b }, ${ a || 1 })`;
|
|
},
|
|
/**
|
|
* Format focus date which is used to display in gantt header (see XML
|
|
* template).
|
|
*
|
|
* @private
|
|
*/
|
|
_getFocusDateFormat() {
|
|
const focusDate = this.state.focusDate;
|
|
switch (this.state.scale) {
|
|
case 'day':
|
|
return focusDate.format('dddd, MMMM DD, YYYY');
|
|
case 'week':
|
|
// const dateStart = focusDate.clone().startOf('week').format('DD MMMM YYYY');
|
|
const dateStart = focusDate.clone().startOf('week').format('YYYY MMMM DD ');
|
|
// const dateEnd = focusDate.clone().endOf('week').format('DD MMMM YYYY');
|
|
const dateEnd = focusDate.clone().endOf('week').format('YYYY MMMM DD ');
|
|
return _.str.sprintf('%s - %s', dateStart, dateEnd);
|
|
case 'month':
|
|
return focusDate.format('MMMM YYYY');
|
|
case 'year':
|
|
return focusDate.format('YYYY');
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
/**
|
|
* Gets the outline stroke's rgba css strings for both the stroke and its hovered state in error state.
|
|
*
|
|
* @return {{ stroke: {string}, hoveredStroke: {string} }}
|
|
* @private
|
|
*/
|
|
_getOutlineStrokeColors() {
|
|
return this._getStrokeAndHoveredStrokeColor(255, 255, 255);
|
|
},
|
|
/**
|
|
* Get pills info
|
|
*
|
|
* @param {Object} row
|
|
* @param {*} groupLevel
|
|
*/
|
|
_getPillsInfo(row, groupLevel) {
|
|
return {
|
|
resId: row.resId,
|
|
pills: row.records,
|
|
groupLevel: groupLevel,
|
|
progressBar: row.progressBar,
|
|
};
|
|
},
|
|
/**
|
|
* Get dates between gantt start and gantt stop date to render gantt slots
|
|
*
|
|
* @private
|
|
* @returns {Moment[]}
|
|
*/
|
|
_getSlotsDates() {
|
|
const token = this.SCALES[this.state.scale].interval;
|
|
const stopDate = this.state.stopDate;
|
|
let day = this.state.startDate;
|
|
const dates = [];
|
|
while (day <= stopDate) {
|
|
dates.push(day);
|
|
day = day.clone().add(1, token);
|
|
}
|
|
return dates;
|
|
},
|
|
/**
|
|
* Gets the stroke's rgba css string corresponding to the provided parameters for both the stroke and its
|
|
* hovered state.
|
|
*
|
|
* @param {number} r - [0, 255]
|
|
* @param {number} g - [0, 255]
|
|
* @param {number} b - [0, 255]
|
|
* @return {{ stroke: {string}, hoveredStroke: {string} }} the css colors.
|
|
* @private
|
|
*/
|
|
_getStrokeAndHoveredStrokeColor(r, g, b) {
|
|
return {
|
|
stroke: this._getCssRGBAColor(r, g, b, 0.5),
|
|
hoveredStroke: this._getCssRGBAColor(r, g, b, 1),
|
|
};
|
|
},
|
|
/**
|
|
* Gets the stroke's rgba css strings for both the stroke and its hovered state.
|
|
*
|
|
* @return {{ stroke: {string}, hoveredStroke: {string} }}
|
|
* @private
|
|
*/
|
|
_getStrokeColors() {
|
|
return this._getStrokeAndHoveredStrokeColor(143, 143, 143);
|
|
},
|
|
/**
|
|
* Gets the stroke's rgba css strings for both the stroke and its hovered state in error state.
|
|
*
|
|
* @return {{ stroke: {string}, hoveredStroke: {string} }}
|
|
* @private
|
|
*/
|
|
_getStrokeErrorColors() {
|
|
return this._getStrokeAndHoveredStrokeColor(211, 65, 59);
|
|
},
|
|
/**
|
|
* Gets the stroke's rgba css strings for both the stroke and its hovered state in warning state.
|
|
*
|
|
* @return {{ stroke: {string}, hoveredStroke: {string} }}
|
|
* @private
|
|
*/
|
|
_getStrokeWarningColors() {
|
|
return this._getStrokeAndHoveredStrokeColor(236, 151, 31);
|
|
},
|
|
/**
|
|
* Initialize the _connectors attribute and delete its associated _rowsAndRecordsDict attribute.
|
|
* @private
|
|
*/
|
|
_initialize_connectors() {
|
|
this._connectors = { };
|
|
delete this._rowsAndRecordsDict;
|
|
},
|
|
/**
|
|
* Gets whether the provided connector creator is the source element of the currently dragged connector.
|
|
*
|
|
* @param {{pill: HTMLElement, connectorCreators: Array<HTMLElement>}} connectorCreatorInfo
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
_isConnectorCreatorDragged(connectorCreatorInfo) {
|
|
return this._connectorInCreation && this._connectorInCreation.data.sourceElement.dataset.id === connectorCreatorInfo.pill.dataset.id;
|
|
},
|
|
/**
|
|
* Mounts the ConnectorContainer Component if needed.
|
|
*
|
|
* @returns {Promise<void>}
|
|
* @private
|
|
*/
|
|
async _mountConnectorContainer() {
|
|
this.el.classList.toggle('position-relative', true);
|
|
if (this._connectorContainerComponent.status === 'mounted') {
|
|
await this._connectorContainerComponent.unmount();
|
|
}
|
|
await this._connectorContainerComponent.mount(this.el);
|
|
await this.updateConnectorContainerComponent();
|
|
},
|
|
/**
|
|
* Prepare view info which is used by GanttRow widget
|
|
*
|
|
* @private
|
|
* @returns {Object}
|
|
*/
|
|
_prepareViewInfo() {
|
|
return {
|
|
colorField: this.colorField,
|
|
progressField: this.progressField,
|
|
consolidationParams: this.consolidationParams,
|
|
state: this.state,
|
|
fieldsInfo: this.fieldsInfo,
|
|
slots: this._getSlotsDates(),
|
|
pillDecorations: this.pillDecorations,
|
|
popoverQWeb: this.popoverQWeb,
|
|
activeScaleInfo: {
|
|
precision: this.cellPrecisions[this.state.scale],
|
|
interval: this.SCALES[this.state.scale].cellPrecisions[this.cellPrecisions[this.state.scale]],
|
|
time: this.SCALES[this.state.scale].time,
|
|
},
|
|
};
|
|
},
|
|
/**
|
|
* @override
|
|
* @private
|
|
*/
|
|
async _render() {
|
|
await this._super(...arguments);
|
|
if (this._isInDom && this.dependencyEnabled) {
|
|
// If the renderer is not yet part of the dom (during first rendering), then
|
|
// the call will be performed in the on_attach_callback.
|
|
await this._mountConnectorContainer();
|
|
}
|
|
},
|
|
/**
|
|
* Renders gantt view and its rows.
|
|
*
|
|
* @override
|
|
*/
|
|
async _renderView() {
|
|
const oldRowWidgets = Object.keys(this.rowWidgets).map((rowId) => {
|
|
return this.rowWidgets[rowId];
|
|
});
|
|
this.rowWidgets = {};
|
|
this.viewInfo = this._prepareViewInfo();
|
|
|
|
this.proms = [];
|
|
const rows = this._renderRows(this.state.rows, this.state.groupedBy);
|
|
let totalRow;
|
|
if (this.totalRow) {
|
|
totalRow = this._renderTotalRow();
|
|
}
|
|
this.proms.push(this._super.apply(this, arguments));
|
|
const proms = this.proms;
|
|
delete this.proms;
|
|
return Promise.all(proms).then(() => {
|
|
_.invoke(oldRowWidgets, 'destroy');
|
|
if (this.firstRendering) {
|
|
this._replaceElement(QWeb.render(this.template_to_use, {widget: this, isMobile: device.isMobile}));
|
|
this.firstRendering = false;
|
|
} else {
|
|
const newContent = $(QWeb.render(this.template_to_use, {widget: this, isMobile: device.isMobile}));
|
|
this.$el.html(newContent[0].innerHTML);
|
|
}
|
|
const $containment = $('<div id="o_gantt_containment"/>');
|
|
const $rowContainer = this.$('.o_gantt_row_container');
|
|
$rowContainer.append($containment);
|
|
if (!this.state.groupedBy.length) {
|
|
$containment.css(this.isRTL ? {right: 0} : {left: 0});
|
|
}
|
|
|
|
rows.forEach((row) => {
|
|
row.$el.appendTo($rowContainer);
|
|
});
|
|
if (totalRow) {
|
|
totalRow.$el.appendTo(this.$('.o_gantt_total_row_container'));
|
|
}
|
|
|
|
if (this._isInDom && !this.disableDragdrop) {
|
|
this._setRowsDroppable();
|
|
}
|
|
|
|
if (this.state.isSample) {
|
|
this._renderNoContentHelper();
|
|
}
|
|
});
|
|
},
|
|
/**
|
|
* Render rows outside the DOM, so that we can insert them to the DOM once
|
|
* they are all ready.
|
|
*
|
|
* @private
|
|
* @param {Object[]} rows recursive structure of records according to
|
|
* groupBys
|
|
* @param {string[]} groupedBy
|
|
* @returns {Promise<GanttRow[]>} resolved with the row widgets
|
|
*/
|
|
_renderRows(rows, groupedBy) {
|
|
let rowWidgets = [];
|
|
|
|
const groupLevel = this.state.groupedBy.length - groupedBy.length;
|
|
// FIXME: could we get rid of collapseFirstLevel in Renderer, and fully
|
|
// handle this in Model?
|
|
let hideSidebar = groupedBy.length === 0;
|
|
if (this.collapseFirstLevel) {
|
|
hideSidebar = this.state.groupedBy.length === 0;
|
|
}
|
|
rows.forEach((row) => {
|
|
const pillsInfo = this._getPillsInfo(row, groupLevel);
|
|
if (groupedBy.length) {
|
|
pillsInfo.groupName = row.name;
|
|
pillsInfo.groupedByField = row.groupedByField;
|
|
}
|
|
const params = {
|
|
canCreate: this.canCreate,
|
|
canCellCreate: this.canCellCreate,
|
|
canEdit: this.canEdit,
|
|
canPlan: this.canPlan,
|
|
isGroup: row.isGroup,
|
|
consolidate: (groupLevel === 0) && (this.state.groupedBy[0] === this.consolidationParams.maxField),
|
|
hideSidebar: hideSidebar,
|
|
isOpen: row.isOpen,
|
|
disableDragdrop: this.disableDragdrop,
|
|
rowId: row.id,
|
|
fromServer: row.fromServer,
|
|
scales: this.SCALES,
|
|
unavailabilities: row.unavailabilities,
|
|
pillLabel: this.pillLabel,
|
|
};
|
|
if (this.thumbnails && row.groupedByField && row.groupedByField in this.thumbnails){
|
|
params.thumbnail = {model: this.fieldsInfo[row.groupedByField].relation, field: this.thumbnails[row.groupedByField],};
|
|
}
|
|
rowWidgets.push(this._renderRow(pillsInfo, params));
|
|
if (row.isGroup && row.isOpen) {
|
|
const subRowWidgets = this._renderRows(row.rows, groupedBy.slice(1));
|
|
rowWidgets = rowWidgets.concat(subRowWidgets);
|
|
}
|
|
});
|
|
return rowWidgets;
|
|
},
|
|
/**
|
|
* Render a row outside the DOM.
|
|
*
|
|
* Note that we directly call the private function _widgetRenderAndInsert to
|
|
* prevent from generating a documentFragment for each row we have to
|
|
* render. The Widget API should offer a proper way to start a widget
|
|
* without inserting it anywhere.
|
|
*
|
|
* @private
|
|
* @param {Object} pillsInfo
|
|
* @param {Object} params
|
|
* @returns {Promise<GanttRow>} resolved when the row is ready
|
|
*/
|
|
_renderRow(pillsInfo, params) {
|
|
const ganttRow = new this.config.GanttRow(this, pillsInfo, this.viewInfo, params);
|
|
this.rowWidgets[ganttRow.rowId] = ganttRow;
|
|
this.proms.push(ganttRow._widgetRenderAndInsert(() => {}));
|
|
return ganttRow;
|
|
},
|
|
/**
|
|
* Renders the total row outside the DOM, so that we can insert it to the
|
|
* DOM once all rows are ready.
|
|
*
|
|
* @returns {Promise<GanttRow} resolved with the row widget
|
|
*/
|
|
_renderTotalRow() {
|
|
const pillsInfo = {
|
|
pills: this.state.records,
|
|
groupLevel: 0,
|
|
groupName: "Total"
|
|
};
|
|
const params = {
|
|
canCreate: this.canCreate,
|
|
canCellCreate: this.canCellCreate,
|
|
canEdit: this.canEdit,
|
|
canPlan: this.canPlan,
|
|
hideSidebar: this.state.groupedBy.length === 0,
|
|
isGroup: true,
|
|
rowId: '__total_row__',
|
|
scales: this.SCALES,
|
|
};
|
|
return this._renderRow(pillsInfo, params);
|
|
},
|
|
/**
|
|
* Set droppable on all rows
|
|
*/
|
|
_setRowsDroppable() {
|
|
// jQuery (< 3.0) rounds the width value but we need the exact value
|
|
// getBoundingClientRect is costly when there are lots of rows
|
|
const firstCell = this.$('.o_gantt_header_scale .o_gantt_header_cell:first')[0];
|
|
_.invoke(this.rowWidgets, 'setDroppable', firstCell);
|
|
},
|
|
/**
|
|
* Returns whether connectors should be rendered or not.
|
|
* The connectors won't be rendered on sampleData as we can't be sure that data are coherent.
|
|
* The connectors won't be rendered on mobile as the usability is not guarantied.
|
|
* The connectors won't be rendered on multiple groupBy as we would need to manage groups folding which seems
|
|
* overkill at this stage.
|
|
*
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
_shouldRenderConnectors() {
|
|
return this._isInDom && !this.state.isSample && !device.isMobile && this.state.groupedBy.length <= 1;
|
|
},
|
|
/**
|
|
* Returns whether connectors should be rendered on particular records or not.
|
|
* This method is intended to be overridden in particular modules in order to set particular record's condition.
|
|
*
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
_shouldRenderRecordConnectors(record) {
|
|
return true;
|
|
},
|
|
/**
|
|
* Toggles popover visibility.
|
|
*
|
|
* @param visible
|
|
* @private
|
|
*/
|
|
_togglePopoverVisibility(visible) {
|
|
const $pills = this.$(this._connectorsCssSelectors.pill);
|
|
if (visible) {
|
|
$pills.popover('enable').popover('dispose');
|
|
} else {
|
|
$pills.popover('hide').popover('disable');
|
|
}
|
|
},
|
|
/**
|
|
* Triggers the on_connector_highlight at the Controller.
|
|
*
|
|
* @param {ConnectorContainer.Connector.props} connector
|
|
* @param {boolean} highlighted
|
|
* @private
|
|
*/
|
|
_triggerConnectorHighlighting(connector, highlighted) {
|
|
this.trigger_up(
|
|
'on_connector_highlight',
|
|
{
|
|
connector: connector,
|
|
highlighted: highlighted,
|
|
});
|
|
},
|
|
/**
|
|
* Triggers the on_pill_highlight at the Controller.
|
|
*
|
|
* @param {HTMLElement} element
|
|
* @param {boolean} highlighted
|
|
* @private
|
|
*/
|
|
_triggerPillHighlighting(element, highlighted) {
|
|
this.trigger_up(
|
|
'on_pill_highlight',
|
|
{
|
|
element: element,
|
|
highlighted: highlighted,
|
|
});
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Handlers
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Handler for Connector connector-creation-abort event.
|
|
*
|
|
* @param {Object} payload
|
|
* @private
|
|
*/
|
|
async _onConnectorCreationAbort(payload) {
|
|
this._connectorInCreation = null;
|
|
const connectorCreatorInfo = this._getConnectorCreatorInfo(payload.data.sourceElement);
|
|
this._triggerPillHighlighting(connectorCreatorInfo.pill, false);
|
|
this.trigger_up('on_connector_end_drag');
|
|
this._togglePopoverVisibility(true);
|
|
},
|
|
/**
|
|
* Handler for Connector connector-creation-done event.
|
|
*
|
|
* @param {Object} payload
|
|
* @private
|
|
*/
|
|
async _onConnectorCreationDone(payload) {
|
|
this._connectorInCreation = null;
|
|
const connectorSourceCreatorInfo = this._getConnectorCreatorInfo(payload.data.sourceElement);
|
|
const connectorTargetCreatorInfo = this._getConnectorCreatorInfo(payload.data.targetElement);
|
|
this.trigger_up('on_connector_end_drag');
|
|
this.trigger_up(
|
|
'on_create_connector',
|
|
{
|
|
masterId: parseInt(connectorSourceCreatorInfo.pill.dataset.id),
|
|
slaveId: parseInt(connectorTargetCreatorInfo.pill.dataset.id),
|
|
});
|
|
this._togglePopoverVisibility(true);
|
|
},
|
|
/**
|
|
* Handler for Connector connector-creation-start event.
|
|
*
|
|
* @param {Object} payload
|
|
* @private
|
|
*/
|
|
async _onConnectorCreationStart(payload) {
|
|
this._connectorInCreation = payload;
|
|
this._togglePopoverVisibility(false);
|
|
const connectorCreatorInfo = this._getConnectorCreatorInfo(payload.data.sourceElement);
|
|
this._triggerPillHighlighting(connectorCreatorInfo.pill, false);
|
|
this.trigger_up('on_connector_start_drag');
|
|
},
|
|
/**
|
|
* Handler for Connector connector-mouseout event.
|
|
*
|
|
* @param {Object} payload
|
|
* @private
|
|
*/
|
|
async _onConnectorMouseOut(payload) {
|
|
this._triggerConnectorHighlighting(payload, false);
|
|
},
|
|
/**
|
|
* Handler for Connector connector-mouseover event.
|
|
*
|
|
* @param {Object} payload
|
|
* @private
|
|
*/
|
|
async _onConnectorMouseOver(payload) {
|
|
this._triggerConnectorHighlighting(payload, true);
|
|
},
|
|
/**
|
|
* Handler for Connector connector-remove-button-click event.
|
|
*
|
|
* @param {Object} payload
|
|
* @private
|
|
*/
|
|
async _onConnectorRemoveButtonClick(payload) {
|
|
this.trigger_up(
|
|
'on_remove_connector',
|
|
{
|
|
masterId: payload.data.masterId,
|
|
slaveId: payload.data.slaveId,
|
|
});
|
|
},
|
|
/**
|
|
* Handler for Connector connector_reschedule_later_button_click event.
|
|
*
|
|
* @param {Object} payload
|
|
* @private
|
|
*/
|
|
async _onConnectorRescheduleLaterButtonClick(payload) {
|
|
this.trigger_up(
|
|
'on_reschedule_according_to_dependency',
|
|
{
|
|
direction: 'forward',
|
|
masterId: payload.data.masterId,
|
|
slaveId: payload.data.slaveId,
|
|
});
|
|
},
|
|
/**
|
|
* Handler for Connector connector_reschedule_sooner_button_click event.
|
|
*
|
|
* @param {Object} payload
|
|
* @private
|
|
*/
|
|
async _onConnectorRescheduleSoonerButtonClick(payload) {
|
|
this.trigger_up(
|
|
'on_reschedule_according_to_dependency',
|
|
{
|
|
direction: 'backward',
|
|
masterId: payload.data.masterId,
|
|
slaveId: payload.data.slaveId,
|
|
});
|
|
},
|
|
/**
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
_onKeydown(ev) {
|
|
this.action = this._getAction(ev);
|
|
if (this.$draggedPill && this.action === 'copy') {
|
|
this.$el.addClass('o_copying');
|
|
this.$el.removeClass('o_grabbing');
|
|
}
|
|
},
|
|
/**
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
_onKeyup(ev) {
|
|
this.action = this._getAction(ev);
|
|
if (this.$draggedPill && this.action === 'reschedule') {
|
|
this.$el.addClass('o_grabbing');
|
|
this.$el.removeClass('o_copying');
|
|
}
|
|
},
|
|
/**
|
|
* Handler for Pill connector-mouseenter event.
|
|
*
|
|
* @param {OdooEvent} ev
|
|
* @private
|
|
*/
|
|
async _onPillMouseEnter(ev) {
|
|
ev.stopPropagation();
|
|
this._triggerPillHighlighting(ev.currentTarget, true);
|
|
},
|
|
/**
|
|
* Handler for Pill connector-mouseleave event.
|
|
*
|
|
* @param {OdooEvent} ev
|
|
* @private
|
|
*/
|
|
async _onPillMouseLeave(ev) {
|
|
ev.stopPropagation();
|
|
this._triggerPillHighlighting(ev.currentTarget, false);
|
|
},
|
|
/**
|
|
* @private
|
|
* @param {OdooEvent} event
|
|
*/
|
|
_onStartDragging(event) {
|
|
this.$draggedPill = event.data.$draggedPill;
|
|
this.$draggedPill.addClass('o_dragged_pill');
|
|
if (this.action === 'copy') {
|
|
this.$el.addClass('o_copying');
|
|
} else {
|
|
this.$el.addClass('o_grabbing');
|
|
}
|
|
if (this.dependencyEnabled) {
|
|
this._triggerPillHighlighting(this.$draggedPill.get(0), false);
|
|
}
|
|
},
|
|
/**
|
|
* Used to give a feedback on the impossibility of moving the pill
|
|
* @private
|
|
*/
|
|
_onStartNoDragging() {
|
|
this.$el.addClass('o_no_dragging');
|
|
},
|
|
/**
|
|
* @private
|
|
*/
|
|
_onStopDragging: function () {
|
|
this.$draggedPill.removeClass('o_dragged_pill');
|
|
this.$draggedPill = null;
|
|
this.$draggedPillClone = null;
|
|
this.$el.removeClass('o_grabbing');
|
|
this.$el.removeClass('o_copying');
|
|
},
|
|
/**
|
|
* @private
|
|
*/
|
|
_onStopNoDragging() {
|
|
this.$el.removeClass('o_no_dragging');
|
|
},
|
|
});
|