/** @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() }); }, });