/*
 *  Copyright 2023 Anyware Services
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

 /**
  * Mixin for contents grid or contents tree grid
  */
Ext.define('Ametys.cms.content.EditContentsView', {
    extends: "Ext.Mixin", 
    
    statics: {
        /**
         * @readonly
         * @private
         * @property {String} _LOCK_STATE_WORKING Constant for lock state when a lock or a unlock was fired
         */
        _LOCK_STATE_WORKING: 'WORKING',
        
        /**
         * @readonly
         * @private
         * @property {String} _LOCK_STATE_IDLE Constant for the lock state when no locking or unlocking process is currently in progress
         */
        _LOCK_STATE_IDLE: 'IDLE'
    },
    
    /**
     * @cfg {Number/String} [workflowEditActionId=2] The workflow action to use when editing the grid
     */

    /**
     * @cfg {String} [messageTarget=Ametys.message.MessageTarget#CONTENT] The message target factory role to use for the event of the bus thrown and listened by the grid
     */
    
    /**
     * @cfg {String} [viewName=default-edition] The name of the view to use when editing the grid
     */
        
    /**
     * @cfg {Boolean} [withSaveBar=true] Enable the docked save bar for the grid.
     */
    /**
     * @property {Boolean} withSaveBar Is the docked save enabled for the grid?
     * @readonly
     */
    /**
     * @cfg {Number} [saveBarPosition] The position of the toolbar between docked items. Default is the last position.
     */
    
    /**
     * Map of objects that needs to be locked/unlocked
     * Linked to issue CMS-8736 when navigating in the grid faster than the network calls
     * @property {Object} _lockedMap map of objects, with the content's id as key
     * @property {Object} _lockedMap.CONTENTID information about the lock/unlock status of a content :
     * @property {String} _lockedMap.CONTENTID.state state of the lock/unlock ('WORKING' if a lock/unlock process was fired, 'IDLE' otherwise)
     * @property {String} _lockedMap.CONTENTID.count count of calls to lock/unlock (+1 for lock, -1 for unlock)
     * @property {Object[]} _lockedMap.CONTENTID.actions list of actions needed (can contains more than 1 if more lock/unlock are added during a network call)
     * @property {String} _lockedMap.CONTENTID.actions.action "lock" or "unlock" : type of action that added this
       @property {Function} _lockedMap.CONTENTID.actions.callback function that will be called after the lock/unlock
     * @private
     */
    
    constructor: function(config)
    {
        this._lockedMap = {};
        
        this.workflowEditActionId = parseInt(config.workflowEditActionId || 2);
        this.viewName = config.viewName || "default-edition";
        
        if (config.allowEdition !== false)
        {
            this.addPlugin(this._createEditPlugin(config));
        }
        
        this.withSaveBar = config.withSaveBar !== false; // default to true
        if (this.withSaveBar)
        {
            if (config.saveBarPosition != undefined)
            {
                this.insertDocked(config.saveBarPosition, this._createSaveBar(config));
            }
            else
            {
                this.addDocked(this._createSaveBar(config));
            }
        }
        
        this.store.on('load', this._onLoad, this);
        this.store.on('update', this._onUpdate, this);
        
        this.addCls('edit-contents-view');
    },
    
    /**
     * @private
     * Listener when the store is loaded
     */
    _onLoad: function ()
    {
        if (this.editingPlugin)
        {
            this._doCancelEdition();
        }
    }, 
    
    /**
     * @private
     * Listener when the store is update to report modification on similar records
     */
    _onUpdate: function(store, activeRecord, operation, modifiedFieldNames, details, eOpts)
    {
        if (!this.changeListeners)
        {
            this.updateChangeListeners();
        }
        
        // Report modification on others records with the same contentid
        let contentId = this._getContentId(activeRecord);
        let records = this._findRecords(contentId);
        for (let record of records)
        {
            if (record != activeRecord)
            {
                if (operation == Ext.data.Model.EDIT)
                {
                    for (let fieldName of modifiedFieldNames)
                    {
                        if (activeRecord.get(fieldName + "_content") !== undefined)
                        {
                            record.set(fieldName + "_content", activeRecord.get(fieldName + "_content"));
                        }
                        if (activeRecord.get(fieldName + "_repeater") !== undefined)
                        {
                            record.set(fieldName + "_repeater", activeRecord.get(fieldName + "_repeater"));
                        }
                        record.set(fieldName, activeRecord.get(fieldName));
                    }
                }
            }
        }
        
        // after a local load, the state may have changed
        this._showHideSaveBar();
        
        if (this.lockedGrid)
        {
            // avoid non matching columns height
            this.lockedGrid.updateLayout()
        }
    },
    
    /** 
     * Call this after setting fields
     */
    updateChangeListeners: function()
    {
        // WARNING this function can be called by the SearchGridRepeaterDialog
        
        let me = this;
        
        if (this.store.model.fields)
        {
            if (this.changeListeners)
            {
                this.store.un('update', checkChange);
            }
            
            this.store.on('update', checkChange);
            this.store.checkChangeNow = checkChange;
            
            this.changeListeners = {};
            for (let field of this.store.model.fields.filter(f => f.ftype))
            {
                recursivelyPrepareField(field, '');
            }
        }
        
        function checkChange(store, record, operation, modifiedFieldNames, details, eOpts)
        {
            if (me.changeListeners && modifiedFieldNames)
            {
                for (let modifieldFieldName of modifiedFieldNames)
                {
                    if (me.changeListeners[modifieldFieldName])
                    {
                        for (let handler of me.changeListeners[modifieldFieldName])
                        {
                            handler({record: record}, record.get(modifieldFieldName), record.getPrevious(modifieldFieldName));
                        }
                    }
                }
            }
        }
        
        function recursivelyPrepareField(field, prefix)
        {
            let changeListeners = field.changeListeners || (field['widget-params'] ? field['widget-params'].changeListeners : null);
            if (changeListeners)
            {
                try
                {
                    changeListeners = JSON.parse(changeListeners);

                    let f = Ametys.form.WidgetManager.getWidget(field.widget, field.ftype || field.type, {});
                    let $class = eval(f.$className);
                    if (!$class.onRelativeValueChange)
                    {
                        me.getLogger().error("The widget " + f.$className + " does not support onRelativeValueChange set in its widget-params 'changeListeners'. Listeners will be ignored.");
                    }
                    else
                    {
                        for (let event of Object.keys(changeListeners))
                        {
                            let paths = Ext.Array.from(changeListeners[event]);
                            for (let path of paths)
                            {
                                function proxyHandler(relativePath, data, newValue, oldValue)
                                {
                                    let handled = $class.onRelativeValueChange(event, relativePath, data, newValue, oldValue);
                                    if (handled === false)
                                    {
                                        me.getLogger().error("The method " + f.$className + "#onRelativeValueChange does not support the event '" + event + " set in the widget-params")
                                    }
                                }
                                Ametys.form.Widget.onRelativeValueChange(path, { grid: me, dataPath: prefix + field.name }, proxyHandler, me);
                            }
                        }
                    }                    
                }
                catch (e)
                {
                    me.getLogger().error("The value of 'widget-params' for '" + field.name + "' is not a valid json. It will be ignored.", e);
                }
            }
            
            // Recursive
            if (field.subcolumns)
            {
                for (let subField of field.subcolumns)
                {
                    recursivelyPrepareField(subField, field.name + "/");
                }
            }
        }
    },
    
    /**
     * @private
     * Create the plugin instance to edit the grid
     * @param {Object} config The constructor configuration
     * @return {Object} The edit plugin
     * @fires
     */
    _createEditPlugin: function(config)
    {
        return {
            ptype: 'cellediting',
            
            clicksToEdit: 1,
            editAfterSelect: true,

            moveEditorOnEnter: true, 
            
            _getContentId: Ext.bind(this._getContentId, this), // For repeater widget
            _findRecords: Ext.bind(this._findRecords, this), // For repeater widget
        
            listeners: {
              'beforeedit': Ext.bind(this._beforeEdit, this),
              'canceledit': Ext.bind(this._cancelEdit, this),
              'edit': Ext.bind(this._edit, this),
              'validateedit': Ext.bind(this._validateEdit, this)
            },
            
            activateCell: this._activateCell
        };
    },
    
    /**
     * @private
     * Override of the plugin cell edition. used also by the repeater grid
     * this = the plugin and not the grid.
     * This method is called when actionable mode is requested for a cell. 
     * @param {Ext.grid.CellContext} position The position at which actionable mode was requested.
     * @param {Boolean} skipBeforeCheck Pass `true` to skip the possible vetoing conditions
     * like event firing.
     * @param {Boolean} doFocus Pass `true` to immediately focus the active editor.
     * @return {Boolean} `true` if this cell is actionable (editable)
     */
    _activateCell: function(position, skipBeforeCheck, doFocus, isResuming)
    {
        const activated = Ext.grid.plugin.CellEditing.prototype.activateCell.apply(this, arguments);
        
        if (activated)
        {
            let field = this.getActiveEditor().field;
            
            if (field.switchToValue !== undefined)
            {
                field.setValue(field.switchToValue);
                field.switchToValue = undefined;
            }
            
            if (field.changeListeners)
            {
                let changeListeners = JSON.parse(field.changeListeners);

                let $class = eval(field.$className);
                if (!$class.onRelativeValueChange)
                {
                    me.getLogger().error("The widget " + field.$className + " does not support onRelativeValueChange set in its widget-params 'changeListeners'. Listeners will be ignored.");
                }
                else
                {
                    for (let event of Object.keys(changeListeners))
                    {
                        let paths = Ext.Array.from(changeListeners[event]);
                        for (let path of paths)
                        {
                            let data = { 
                                grid: this.grid 
                            };
                            
                            if (path.startsWith('..'))
                            {
                                data.dataPath = arguments[6] + '/' + field.name; 
                                data.record=  arguments[7];
                            }
                            else
                            {
                                data.dataPath = field.name; 
                                data.record=  this.getActiveRecord();
                            }
                            
                            $class.onRelativeValueChange(event, path, field, Ametys.form.Widget.getRelativeValue(path, data), null);
                        }
                    }
                }                                  
            }
            
            if (field.updateAdditionalWidgetsConf)
            {
                field.updateAdditionalWidgetsConf({contentInfo: { contentId: this._getContentId(position.record) }});
            }
        }
        
        return activated;
    },
    
        /**
     * @private
     * Create the save bar when results are edited
     * @param {Object} config The constructor configuration
     */
    _createSaveBar: function(config)
    {
        return {
            xtype: 'container',
            id: this.getId() + "-savebar",
            cls: 'hint',
            dock: 'top',
            hidden: true,
            border: false,
            layout: {
                type: 'hbox',
                pack: 'start',
                align: 'stretch'
            },
            
            items: [
                    {
                        xtype: 'component',
                        cls: 'a-text',
                        html: "{{i18n UITOOL_CONTENTEDITIONGRID_SAVEBAR_MESSAGE}}",
                        flex: 1
                    },
                    {
                        xtype: 'button',
                        width: 100,
                        height: 22,
                        text: "{{i18n UITOOL_CONTENTEDITIONGRID_SAVEBAR_SAVE_LABEL}}",
                        handler: function() { this._saveEdition(); },
                        scope: this
                    },
                    {
                        xtype: 'button',
                        width: 100,
                        height: 22,
                        text: "{{i18n UITOOL_CONTENTEDITIONGRID_SAVEBAR_UNSAVE_LABEL}}",
                        handler: Ext.bind(this.discardChanges, this, [true, null]),
                        scope: this
                    }
            ]
        };
    },
    
    /**
     * Get the save bar
     * @return {Ext.toolbar.Paging} The save toolbar
     */
    getSaveBar: function()
    {
        return Ext.getCmp(this.getId() + "-savebar");
    },
    
    /**
     * @protected
     * Get the content id of the given record
     * @param {Ext.data.Model} record The record involved
     * @return {String} The content id of the record
     */
    _getContentId: function(record)
    {
        return record.getId();
    },
    
    /**
     * @protected
     * Get the record by content id
     * @param {String} contentId The content id to consider
     * @return {Ext.data.Model[]} The records representing this content
     */
    _findRecords: function(contentId)
    {
        return [this.getStore().getById(contentId)];
    },

    /**
     * Locks a content when entering in edition
     * @param {Ext.grid.plugin.CellEditing} editor The editor plugin
     * @param {Object} e An edit event with the following properties:
     * @param {Ext.grid.Panel} e.grid The grid
     * @param {Ext.data.Model} e.record The record being edited
     * @param {String} e.field The field name being edited
     * @param {Object} e.value The value for the field being edited.
     * @param {HTMLElement} e.row The grid table row
     * @param {Ext.grid.column.Column} e.column The grid {@link Ext.grid.column.Column Column} defining the column that is being edited.
     * @param {Number} e.rowIdx The row index that is being edited
     * @param {Number} e.colIdx The column index that is being edited
     * @param {boolean} e.cancel Set this to true to cancel the edit or return false from your handler.
     * @private
     */
    _beforeEdit: function(editor, e)
    {
        let field = e.column.getEditor();
        
        if (field.isVisible(true) && editor.activeEditor == e.record)
        {
            // sometimes before edit is thrown twice in a row
            return false;
        }
        
        if (e.value === undefined && field.defaultValue !== undefined)
        {
            field.switchToValue = field.defaultValue;
        }
        else
        {
            field.switchToValue = undefined;
        }
        
        if (window.event)
        {
            var originalEvent = Ext.create(Ext.event.Event, window.event);
            if (originalEvent.target && /^a$/i.test(originalEvent.target.tagName))
            {
                // We do not want to edit here. The user was clicking on the link!
                return false;
            }
        }
        
        // Is this field modifiable for the current user?
        if ((e.record.data['notEditableData'] === true 
            || e.record.data['notEditableDataIndex'] && Ext.Array.contains(e.record.data['notEditableDataIndex'], e.field))
            || Ext.fly(e.cell).hasCls('cell-disabled'))
        {
            // Repeater widget need to be always clickable (when not empty) to be able to see values
            if (e.column.type != 'repeater' 
                || e.value._size == 0 
                || e.record.data['notClickableRepeater'] && Ext.Array.contains(e.record.data['notClickableRepeater'], e.field))
            {
                this.getLogger().debug("Cannot edit field " + e.field + " for content " + this._getContentId(e.record));
                return false;
            }
        }
        
        // Is an external attribute
        else if (e.record.data[e.field + '_external_status'] == 'external')
        {
            this.getLogger().debug("Cannot edit field " + e.field + " for content " + this._getContentId(e.record) + " because synchronization status is currently external");
            return false;
        }
        
        // Has the computing already been done ?
        else if (e.record.data['notEditableData'] !== true && !e.record.data['notEditableDataIndex'])
        {
            Ametys.cms.content.ContentDAO.getContent(this._getContentId(e.record), Ext.bind(this._beforeEditContentCB, this, [e.record], 1));
            
            var editableMetadata = [];
            
            var columns = this.getColumns();
            for (let column of columns)
            {
                if (column.getEditor() != null)
                {
                    editableMetadata.push(column.dataIndex);
                }
            }
            
            e.record.set('notEditableDataIndex', []);
            
            Ametys.data.ServerComm.callMethod({
                role: 'org.ametys.cms.content.ContentHelper',
                methodName: 'getContentAttributeDefinitionsAsJSON',
                parameters: [this._getContentId(e.record), editableMetadata, true],
                priority: Ametys.data.ServerComm.PRIORITY_MAJOR, 
                callback: {
                    handler: Ext.bind(this._beforeEditContentCB2, this, [e.record], true),
                    scope: this
                },
                waitMessage: true,
                errorMessage: {
                    msg: "{{i18n plugin.cms:PLUGINS_CMS_TOOL_CONTENT_FORMDEFINITION_ERROR}} '" + this._getContentId(e.record) + "'",
                    category: Ext.getClassName(this)
                }
            });
        }
        
        else if (this.getModifiedFields(e.record).length == 0)
        {
            Ametys.cms.content.ContentDAO.getContent(this._getContentId(e.record), Ext.bind(this._beforeEditContentCB, this, [e.record], 1));
        }
        
        // After the setValue, the size of the field may have changed... lets realign it
        var me = this;
        
        window.setTimeout(function() {
            if (me.editingPlugin && me.editingPlugin.activeEditor)
            {
                 me.editingPlugin.activeEditor.realign(true)
            }
        }, 1)

    },

    /**
     * Callback for #_beforeEdit to get the content associated to the record
     * @param {Ametys.cms.content.Content} content The content being edited
     * @param {Ext.data.Model} record The record being edited
     * @private
     */
    _beforeEditContentCB: function(content, record)
    {
        if (content == null)
        {
            // An error was already shown to the user by the DAO
            record.set('notEditableData', true);
            this._doCancelEdition();
        }
        
        // Check if edit action is possible 
        else if (content.getAvailableActions().indexOf(this.workflowEditActionId) == -1)
        {
            record.set('notEditableData', true);
            this._doCancelEdition();
            Ametys.log.ErrorDialog.display({
                title: "{{i18n UITOOL_CONTENTEDITIONGRID_LOCK_ERROR_TITLE}}",
                text: "{{i18n UITOOL_CONTENTEDITIONGRID_LOCK_ERROR_DESC}}",
                details: "The content '" + content.getId() + "' cannot be edited with workflow action '" + this.workflowEditActionId + "'. Available actions are " + content.getAvailableActions(),
                category: this.self.getName()
            });
        }

        else 
        {
            content.setMessageTarget(this.messageTarget);
            // Lock content
            this._lockOrUnlockContent(content, true, Ext.bind(this._beforeEditLockCB, this), false);
        }
    },
    
    /**
     * @private
     * Callback to determine which fields can be edited
     * @param {XMLElement} response The server response
     * @param {Object} args Additionnal arguments. Empty
     * @param {Ext.data.Model} record The edited record
     */
    _beforeEditContentCB2: function(response, args, record)
    {
        var notEditableDataIndex = [];
        function seekUnwritable(obj, prefix)
        {
            Ext.Object.each(obj, function(key, value, obj) {
                if (value['can-not-write'] == true)
                {
                    notEditableDataIndex.push(prefix + key);
                }
                if (value['type'] == "composite" || value['type'] == "repeater" )
                {
                    seekUnwritable(value.elements, prefix + key + "/");
                }
            });
        }
        seekUnwritable(response.attributes.elements, "");
        
        record.set('notEditableDataIndex', notEditableDataIndex);
        
        // reverse uneditable fields
        var changes = record.getChanges()
        Ext.Object.each(changes, function(key, value) {
            if (Ext.Array.contains(record.data['notEditableDataIndex'], key))
            {
                // revert
                record.set(key, record.modified[key]);
            }
        });
        // if revert was successful, save bar may need to be hidden
        this._showHideSaveBar();
        
        if (this.editingPlugin.editing && Ext.Array.contains(notEditableDataIndex, this.editingPlugin.getActiveColumn().dataIndex))
        {
            if (this.editingPlugin.getActiveColumn().type != 'repeater')
            {
                this._doCancelEdition();
            }
            else
            {
                this.editingPlugin.getActiveColumn().field.setReadOnly();
            }
        }
        else
        {
//            var me = this;
//            me.editingPlugin.activeEditor.realign(true)
        }
    },
    
    /**
     * Callback for #_beforeEditContentCB after lock process was done
     * @param {boolean} success Is lock ok?
     * @private
     */
    _beforeEditLockCB: function(success)
    {
        if (!success)
        {
            // cancel edition
            this._doCancelEdition();
        }
        
        // finally, we can, edit normally
    },
    
    /**
     * @private
     * The current edition has to be silently canceled.
     */
    _doCancelEdition: function()
    {
//        this.editingPlugin.suspendEvent('canceledit');
        this.editingPlugin.cancelEdit();
//        this.editingPlugin.resumeEvent('canceledit');
    },
    
    /**
     * Unlocks a content when canceling edition (if no other modifications where running)
     * @param {Ext.grid.plugin.CellEditing} editor The editor plugin
     * @param {Object} e An edit event with the following properties:
     * @param {Ext.grid.Panel} e.grid - The grid
     * @param {Ext.data.Model} e.record - The record being edited
     * @param {String} e.field - The field name being edited
     * @param {Object} e.value - The value for the field being edited.
     * @param {HTMLElement} e.row - The grid table row
     * @param { Ext.grid.column.Column} e.column - The grid {@link Ext.grid.column.Column Column} defining the column that is being edited.
     * @param {Number} e.rowIdx - The row index that is being edited
     * @param {Number} e.colIdx - The column index that is being edited
     * @private
     */
    _cancelEdit: function(editor, e)
    {
        if (this.getModifiedFields(e.record).length == 0)
        {
            var tmpContent = Ext.create("Ametys.cms.content.Content", { locked: true, id: this._getContentId(e.record), messageTarget: this.messageTarget });
            // Unlock content
            this._lockOrUnlockContent(tmpContent, false, function (success) { /* ignore */ }, true);
        }
    },
    
     /**
     * Cancel the change if the editing field has trigger the opening of a dialog box
     * @param {Ext.grid.plugin.CellEditing} editor The editor plugin
     * @param {Object} context The editing context
     * @private
     */
    _validateEdit: function (editor, context)
    {
        if (editor.activeEditor && editor.activeEditor.field)    
        {
            return !editor.activeEditor.field.triggerDialogBoxOpened;
        }
        
        return true;
    },
    
    
    /**
     * When an edition ends, add in the search screen (if necessary) a save/discard toolbar 
     * @param {Ext.grid.plugin.CellEditing} editor The editor plugin
     * @param {Object} e An edit event with the following properties:
     * @param {Ext.grid.Panel} e.grid - The grid
     * @param {Ext.data.Model} e.record - The record being edited
     * @param {String} e.field - The field name being edited
     * @param {Object} e.value - The value for the field being edited.
     * @param {Object} e.originalValue - The value for the field before the edit
     * @param {HTMLElement} e.row - The grid table row
     * @param { Ext.grid.column.Column} e.column - The grid {@link Ext.grid.column.Column Column} defining the column that is being edited.
     * @param {Number} e.rowIdx - The row index that is being edited
     * @param {Number} e.colIdx - The column index that is being edited
     * @private
     */
    _edit: function(editor, e)
    {
        // We want to consider approximative equals
        if (e.record.modified != undefined && (Ext.isEmpty(e.record.modified[e.field]) && Ext.isEmpty(e.record.data[e.field]))
            // We want to compare arrays of values
            || e.record.modified != undefined && Ext.isArray(e.record.modified[e.field]) && Ext.isArray(e.record.data[e.field]) && Ext.Array.equals(e.record.modified[e.field], e.record.data[e.field]))
        {
            // We will reset such values
            e.record.set(e.field, e.record.modified[e.field]);
        }
        
        if (e.record.modified == undefined || e.record.modified[e.field] === undefined)
        {
            this._cancelEdit(editor, e);
        }
        
        this._showHideSaveBar();
        
        // Reset after ending, so the next edition may not compare with old values (such as combobox RUNTIME-3972)
        e.column.getEditor().reset();
    },

    /**
     * @private
     * Remove the search and display the savebar instead (or the opposite) depending on #isModeEdition
     */
    _showHideSaveBar: function(forceVisible)
    {
        let visible = forceVisible !== undefined ? forceVisible : this.isModeEdition(); 
        
        if (this.withSaveBar)
        {
            this.getSaveBar().setVisible(visible);
        }
        if (this.paginationEnabled)
        {
            this.getPageBar().setDisabled(visible);
        }
        
        // prevent sort that would trash modifications
        var columns = this.getColumns();
        for (var i = 0; i < columns.length; i++)
        {
            if (visible)
            {
                columns[i].oldSortable = columns[i].oldSortable || columns[i].sortable;
                columns[i].sortable = false;
            }
            else
            {
                columns[i].sortable = columns[i].oldSortable || columns[i].sortable;
                columns[i].oldSortable = null;
            }
        }
        
        this.fireEvent("dirtychange", visible);
    },
    
    /**
     * Is in edition mode?
     * @return {boolean} true if any record already is modified
     */
    isModeEdition: function()
    {
        return this.getModifiedRecords().length > 0;
    },
    
    /**
     * The list of modified records
     * @private
     */
    getModifiedRecords: function()
    {
        let modifiedRecords = [];
        
        let records = this.getStore().getModifiedRecords();
        if (this.getStore().root && this.getStore().root.modified)
        {
            records.push(this.getStore().root);
        }
        
        for (let record of records)
        {
            if (this.getModifiedFields(record).length > 0)
            {
                modifiedRecords.push(record);
            }
        }
        return modifiedRecords;
    },
    
    /**
     * Get the modified worthable fields in the record
     * @param {Ext.data.Model} record The record to check
     * @return {String[]} The modified fieldnames
     */
    getModifiedFields: function(record)
    {
        const ignoreFields = ['notEditableData', 'notEditableDataIndex', 'notClickableRepeater', 'leaf'];
        
        if (record.get('notEditableData'))
        {
            return [];
        }
        
        let notEditableDataIndex = record.get('notEditableDataIndex');
        if (notEditableDataIndex)
        {
            for (let nedi of notEditableDataIndex)
            {
                ignoreFields.push(nedi);
            }
        }
        
        let modified = Object.keys(record.modified || {});
        return modified.filter(v => !ignoreFields.includes(v));
    },
    
    /**
     * @private
     * Save the current edition in the store
     * @param {Function} [callback] Called at the end of the save process
     * @param {Boolean} callback.success True if the save was done correctly
     */
    _saveEdition: function(callback)
    {
        let me = this;
        
        callback = callback || Ext.emptyFn;
        
        function getTitle(record, contentId)
        {
            return Ametys.plugins.cms.search.SearchGridHelper.renderMultilingualString(record.get('title'))
                        || record.get('name')
                        || contentId;
        }
        
        try
        {
            if (this.editingPlugin.editing)
            {
                this.editingPlugin.completeEdit();
            }
            
            var contentIds = [];
            var invalidContentTitles = [];
            Ext.Array.forEach(this.getModifiedRecords(), function(record) {
                if (me._isValid(me, record))
                {
                    let contentId = me._getContentId(record);
                    if (!contentIds.includes(contentId))
                    {
                        contentIds.push(contentId);
                    }
                }
                else
                {
                    let contentId = me._getContentId(record);
                    if (!invalidContentTitles.includes(contentId))
                    {
                        invalidContentTitles.push(getTitle(record, contentId));
                    }
                }
            });
            
            if (contentIds.length > 0)
            {
                if (invalidContentTitles.length > 0)
                {
                    let oldCb = callback;
                    callback = function()
                    {
                        invalidContents(invalidContentTitles)
                        
                        oldCb.apply(this, arguments);
                    }
                }
                Ametys.cms.content.ContentDAO.getContents(contentIds, Ext.bind(this._saveEditionWithContents, this, [callback], 1));
            }
            else
            {
                if (invalidContentTitles.length > 0)
                {
                    invalidContents(invalidContentTitles)
                }
                callback(false);
            }
        }
        catch (e)
        {
            callback(false);
            throw e;
        }
        
        function invalidContents(contentsTitle)
        {
            Ametys.form.SaveHelper.SaveErrorDialog.showErrorDialog("{{i18n UITOOL_CONTENTEDITIONGRID_VALUE_ERROR_TITLE}}", "{{i18n UITOOL_CONTENTEDITIONGRID_VALUE_ERROR_DESC}}<ul><li>" + contentsTitle.join("</li><li>") + "</li></ul>");
        }
    },
    
    /**
     * @private
     * Test if the recrod is valid
     * @param {Ext.grid.Panel} grid The host grid
     * @param {Ext.data.Model} record The record to check
     * @return {Boolean} true if all the columns are valid, false otherwise
     */
    _isValid: function(grid, record)
    {
        for (let column of grid.getColumns())
        {  
            if (!Ametys.plugins.cms.search.SearchGridHelper.isValidValue(column, record, undefined, grid.getStore()))
            {
                return false;
            }
        }
        return true;
    },

    /**
     * @private
     * continue the save edition process
     * @param {Ametys.cms.content.Content[]} contents The contents modified
     * @param {Function} [callback] Called at the end of the save process
     * @param {Boolean} callback.success True if the save was done correctly
     */
    _saveEditionWithContents: function(contents, callback)
    {
        try
        {
            var me = this;
            
            // We are going to loop on all contents
            // But we want to call the callback only at the very end of theses asynchronous calls
            // So lets ignore the first n-1 calls.
            var barrierCallback = Ext.Function.createBarrier(contents.length, callback);
            
            Ext.Array.forEach(contents, function(content) {
                
                me._doSaveEdition(content, false, barrierCallback);
                
                Ext.create("Ametys.message.Message", {
                    type: Ametys.message.Message.WORKFLOW_CHANGING,
                    
                    targets: {
                        id: me.messageTarget,
                        parameters: { contents: [content] }
                    }
                });
            });
        }
        catch (e)
        {
            callback(false);
            throw e;
        }
        
        if (contents && contents.length == 0)
        {
            // no content modified
            callback(true);
        }
    },
    
    /**
     * @private
     * Retrieves the given record's changes prefixed with the given prefix
     * @param {Ext.data.Model} record The record
     * @param {String} prefix the prefix
     * @return {Object} the prefixed record's changes
     */
    _getPrefixedChanges: function(record, prefix)
    {
        var changesBefore = record.getChanges();
        var changesAfter = {};
        
        Ext.Array.each(record.getFields(), function (field) {
            var fieldName = field.getName(),
                value = changesBefore[fieldName];
                
            if (value === undefined && Object.keys(changesBefore).includes(fieldName))
            {
                value = null;
            }
                
            var key = fieldName.indexOf("_") === 0 ? "_" + prefix + fieldName.substring(1) : prefix + fieldName;
            if (field.ftype == "repeater" && Ext.isObject(value))
            {
                // Repeaters
                Ext.Object.each(value, function (keySuffix, subValue) {
                    if (!Ext.String.endsWith(keySuffix, "__externalDisableConditionsValues"))
                    {
                        if (keySuffix.indexOf("_") === 0 && keySuffix.indexOf("[") === 1)
                        {
                            changesAfter["_" + key + keySuffix.substring(1)] = subValue;
                        }
                        else if (keySuffix.indexOf("_") === 0)
                        {
                            changesAfter["_" + key + "/" + keySuffix.substring(1)] = subValue;
                        }
                        else if (keySuffix.indexOf("[") === 0)
                        {
                            changesAfter[key + keySuffix] = subValue;
                        }
                        else
                        {
                            changesAfter[key + "/" + keySuffix] = subValue;
                        }
                    }
                });
            }
            else if (field.ftype == "date" && Ext.isDate(value))
            {
                changesAfter[key] = Ext.Date.format(value, Ext.Date.patterns.ISO8601Date);
            }
            else if (field.ftype == "datetime" && Ext.isDate(value))
            {
                changesAfter[key] = Ext.Date.format(value, Ext.Date.patterns.ISO8601DateTime);
            }
            else if (value !== undefined)
            {
                changesAfter[key] = value;
            }    
            
        });
        
        return changesAfter;
    },
    
    /**
     * @private
     * Find a field label using its name
     * @param {String} fdName The field full name
     * @param {String} defaultLabel If not colupmn matches, take this as default value. Otherwise will be the fdName
     * @return {String} A readable label
     */
    _findColumnLabelByDataIndex: function(fdName, defaultLabel)
    {
        const me = this;
        
        function seek(columns, dataPaths)
        {
            for (let column of columns)
            {
                if ((column.dataIndex || column.name) == dataPaths[0])
                {
                    if (dataPaths.length == 1)
                    {
                        return column.text || column.label;
                    }
                    else
                    {
                        let subcolumns = column.columns;
                        if (!subcolumns)
                        {
                            subcolumns = me.getStore().getModel().getField(dataPaths[0]).subcolumns;
                        }
                        
                        return column.text + " (" + dataPaths[1] + ") > " + seek(subcolumns, dataPaths.slice(2));
                    }
                }
            }
            
            return null; 
        }

        return seek(this.getColumns(), fdName.split('.'))
            || defaultLabel
            || fdName;
    },
    
    /**
     * @private
     * continue the save edition process
     * @param {Object} response The XMLHTTPRequest response object
     * @param {Object} args The arguments of the sendMessage
     */
    _saveEditionCB: function(response, args)
    {
        var content = args['content'];
        var callback = args['callback'];

        if (Ametys.data.ServerComm.handleBadResponse("{{i18n PLUGINS_CMS_SAVE_ACTION_ERROR}}", response, this.self.getName()))
        {
            // Workflow did not really changed, but WORKFLOW_CHANGING has been sent, elements could be waiting for update
            Ext.create("Ametys.message.Message", {
                type: Ametys.message.Message.WORKFLOW_CHANGED,
                
                targets: {
                    id: this.messageTarget,
                    parameters: { contents: [content] }
                }
            });
            
            callback(false);
            return;
        }
        
        var detailedMsg = "";
        
        var success = Ext.dom.Query.selectValue ('> ActionResult > success', response) == "true";
        
        // Handling workflow errors
        var workflowErrors = Ext.dom.Query.select ('> ActionResult > workflowValidation > error', response);
        if (!success && workflowErrors.length > 0)
        {
            detailedMsg += '<ul>';
            for (var i=0; i < workflowErrors.length; i++)
            {
                var errorMsg = Ext.dom.Query.selectValue("", workflowErrors[i]);
                detailedMsg += '<li>' + errorMsg + '</li>';
            }
            detailedMsg += '</ul>';
            
            Ametys.form.SaveHelper.SaveErrorDialog.showErrorDialog ("\"" + content.getTitle() + "\" {{i18n plugin.core-ui:PLUGINS_CORE_UI_SAVE_ACTION_FAILED_TITLE}}", "{{i18n PLUGINS_CMS_SAVE_ACTION_FAILED_WORKFLOW_DESC}}", detailedMsg);
            
            // Workflow did not really changed, but WORKFLOW_CHANGING has been sent, elements could be waiting for update
            Ext.create("Ametys.message.Message", {
                type: Ametys.message.Message.WORKFLOW_CHANGED,
                
                targets: {
                    id: this.messageTarget,
                    parameters: { contents: [content] }
                }
            });
            
            callback(false);
            return;
        }
        
        // Handling field errors
        var errors = Ext.dom.Query.select ('> ActionResult > * > fieldResult > error', response);
        if (!success && errors.length > 0)
        {
            var msg = "{{i18n PLUGINS_CMS_SAVE_ACTION_FAILED_DESC}}";
            msg += errors.length  == 1 ? "{{i18n PLUGINS_CMS_SAVE_ACTION_FAILED_DESC_SINGLE}}" : errors.length + "{{i18n PLUGINS_CMS_SAVE_ACTION_FAILED_DESC_MULTI}}";

            var detailedMsg = this._getFieldsDetailedMessage(errors);
            
            let dialogTitle = "\"" + content.getTitle() + "\" {{i18n plugin.core-ui:PLUGINS_CORE_UI_SAVE_ACTION_FAILED_TITLE}}";
            Ametys.form.SaveHelper.SaveErrorDialog.showErrorDialog (dialogTitle, msg, detailedMsg);
            
            // Workflow did not really changed, but WORKFLOW_CHANGING has been sent, elements could be waiting for update
            Ext.create("Ametys.message.Message", {
                type: Ametys.message.Message.WORKFLOW_CHANGED,
                
                targets: {
                    id: this.messageTarget,
                    parameters: { contents: [content] }
                }
            });
            
            callback(false);
            return;
        }
        
        // Handling field warnings
        var warnings = Ext.dom.Query.select ('> ActionResult > * > fieldResult > warning', response);
        if (!success && warnings.length > 0)
        {
            var msg = warnings.length  == 1 ? "{{i18n PLUGINS_CMS_SAVE_ACTION_WARNING_DESC_SINGLE}}" : "{{i18n PLUGINS_CMS_SAVE_ACTION_WARNING_DESC_MULTI}}";
            var detailedMsg = this._getFieldsDetailedMessage(warnings);
            var question = "{{i18n PLUGINS_CMS_SAVE_ACTION_WARNING_DESC_QUESTION}}";

            let dialogTitle = "\"" + content.getTitle() + "\" {{i18n plugin.core-ui:PLUGINS_CORE_UI_SAVE_ACTION_WARNING_TITLE}}";
            Ametys.form.SaveHelper.SaveErrorDialog.showWarningDialog (dialogTitle, msg, detailedMsg, question, Ext.bind(this._showWarningsCb, this, [args], 1));
            
            return;
        }
        
        var infos = Ext.dom.Query.select ('> ActionResult > * > fieldResult > info', response);
        if (success && infos.length > 0)
        {
            for (var i=0; i < infos.length; i++)
            {
                var infoData = infos[i].parentNode.parentNode;
                var fieldId = infoData.tagName;
                    
                let notifTitle = "\"" + Ext.String.escapeHtml(content.getTitle()) + "\" {{i18n plugin.cms:PLUGINS_CMS_SAVE_ACTION_CONTENT_MODIFICATION}}";
                var message = Ext.dom.Query.selectValue("", infos[i]);
                if (fieldId !== '_global')
                {
                    let defaultLabel = Ext.dom.Query.selectValue ('> fieldLabel', infoData) || fieldId;
                    var label = this._findColumnLabelByDataIndex(fieldId, defaultLabel);
                    message = '<b>' + label + '</b><br/>' + message;
                }
            
                Ametys.notify({
                    title: notifTitle,
                    description: message
                });
            }
        }
        
        // Object content may be outdated, just trust its id
        var contentId = content.getId();
        
        // graphically finish the save process
        for (let r of this._findRecords(contentId)) 
        {
            r.commit(null, Object.keys(r.modified)); 
        }
        this._showHideSaveBar();

        this._sendMessages(contentId);

        callback(true);
    },
    
    _sendMessages: function(contentId)
    {
        Ext.create("Ametys.message.Message", {
            type: Ametys.message.Message.MODIFIED,
            
            targets: {
                id: this.messageTarget,
                parameters: { ids: [contentId] }
            }
        });
        
        Ext.create("Ametys.message.Message", {
            type: Ametys.message.Message.LOCK_CHANGED,
            
            targets: {
                id: this.messageTarget,
                parameters: { ids: [contentId] }
            }
        });
        
        Ext.create("Ametys.message.Message", {
            type: Ametys.message.Message.WORKFLOW_CHANGED,
            
            targets: {
                id: this.messageTarget,
                parameters: { ids: [contentId] }
            }
        });
    },
    
    /**
     * @private
     * Callback function called after user chose to ignore warnings or not
     * @param {Boolean} ignoreWarnings true if the user choose to ignore warnings and save anyway, false otherwise
     * @param {Object} args The arguments of the sendMessage
     */
    _showWarningsCb: function (ignoreWarnings, args)
    {
        var content = args['content'];
        var callback = args['callback'];
        
        if (ignoreWarnings)
        {
            this._doSaveEdition(content, true, callback);
        }
        else
        {
            // Workflow did not really changed, but WORKFLOW_CHANGING has been sent, elements could be waiting for update
            Ext.create("Ametys.message.Message", {
                type: Ametys.message.Message.WORKFLOW_CHANGED,
                
                targets: {
                    id: this.messageTarget,
                    parameters: { contents: [content] }
                }
            });
            
            callback(false);
        }
    },
    
    /**
     * 
     */
    _doSaveEdition: function(content, ignoreWarnings, callback)
    {
        var me = this;
        var record = me._findRecords(content.getId())[0]; // we can take the first record since all records should display the same value
            
        var params = {};
        params.values = me._getPrefixedChanges(record, "content.input.");
        params.contentId = me._getContentId(record);
        params.quit = true;
        params['content.view'] = null;
        params['local.only'] = true;
        params['ignore.warnings'] = ignoreWarnings;
        
        Ametys.data.ServerComm.send({
            plugin: 'cms', 
            url: 'do-action/' + me.workflowEditActionId, 
            parameters: params,
            waitMessage: {
                target: me,
                msg: "{{i18n CONTENT_EDITION_SAVING}}"
            },
            priority: Ametys.data.ServerComm.PRIORITY_MAJOR, 
            callback: {
                scope: me,
                handler: me._saveEditionCB,
                arguments: {
                    content: content,
                    callback: callback
                }
            }
        });
    },
        
    /**
     * @private
     * Retrieves detailed message for given fields
     * @param {HTMLElement} fields The XML containing fields and correspondin messages
     */
    _getFieldsDetailedMessage: function (fields)
    {
        var detailedMessage = '<ul>';
            
        for (var i=0; i < fields.length; i++)
        {
            var fieldData = fields[i].parentNode.parentNode;
            var fieldId = fieldData.tagName;
                
            var message = Ext.dom.Query.selectValue("", fields[i]);
            if (fieldId == '_global')
            {
                detailedMessage += '<li>' + message + '</li>';
            }
            else
            {
                let defaultLabel = Ext.dom.Query.selectValue ('> fieldLabel', fieldData) || fieldId;
                var label = this._findColumnLabelByDataIndex(fieldId, defaultLabel);
                detailedMessage += '<li><b>' + label + '</b><br/>' + message + '</li>';
            }
        }
        
        detailedMessage += '</ul>';
        
        return detailedMessage;
    },
    
    /**
     * Discard modifications
     * @param {Boolean} prompt Ask user
     * @param {Function} callback The callback if discard is accepted, when done
     */
    discardChanges: function(prompt, callback)
    {
        if (prompt != false)
        {
            // Quit the edition mode ?
            var me = this;
            Ametys.Msg.confirm("{{i18n plugin.cms:UITOOL_CONTENTEDITIONGRID_SAVEBAR_UNSAVE_CONFIRM_LABEL}}", 
                    "{{i18n plugin.cms:UITOOL_CONTENTEDITIONGRID_SAVEBAR_UNSAVE_CONFIRM_DESC}}",
                    Ext.bind(this._unsaveEditionCB, this, [callback], 1)
            );
        }
        else
        {
            this._unsaveEditionCB('yes', callback);
        }
    },
    
    /**
     * @private
     * Discard the current edition in the store
     * @param {String} answer 'yes' to effectively do it
     * @param {Function} [callback] The function to call after the process is over
     */
    _unsaveEditionCB: function(answer, callback)
    {
        let me = this;
        
        if (answer != 'yes')
        {
            return;
        }
        
        if (this.editingPlugin.editing)
        {
            this._doCancelEdition();
        }

        Ametys.data.ServerComm.suspend();
        let modifiedRecords = this.getModifiedRecords();
        Ext.Array.forEach(modifiedRecords, function(record) {
            var tmpContent = Ext.create("Ametys.cms.content.Content", { locked: true, id: me._getContentId(record), messageTarget: this.messageTarget });
            this._lockOrUnlockContent(tmpContent, false, function (success) { /* ignore */ }, true);
            
            // Reseting manually advanced data (repeater, content...)
            Ext.Object.each(record.data, function(name, value) {
                if (Ext.String.endsWith(name, "_initial"))
                {
                    let initialName = name.substring(0, name.length - "_initial".length);
                    record.data[initialName] = Ext.clone(value);
                }                    
           })
        }, this);
        Ametys.data.ServerComm.restart();

        this.getStore().rejectChanges();
        if (this.getStore().root)
        {
            this.getStore().root.reject()
        }
        
        this._showHideSaveBar();
        
        // TODO
        // Reload relative fields
        for (let record of modifiedRecords)
        {
            this.getStore().checkChangeNow(this.getStore(), record, null, Object.keys(this.changeListeners), null, null);
        }
        
        if (callback)
        {
            callback();
        }
    },
    
    /**
     * Lock or unlock a content and call it's callback.
     * If lock is asked:
     * - the content will not be locked if unlock have been called without a previous lock.
     * - if multiple locks are done, no further network calls will be made (instead an unlock occurs)
     * If unlock is asked, if multiple unlocks are done, no further network calls will be made (instead a lock occurs)
     * @param {Ametys.cms.content.Content} content content to lock or unlock
     * @param {Boolean} lock true to lock the content, false to unlock it
     * @param {Function} callback The method to call
     * @param {Boolean} callback.success true if the lock was a success, else false
     * @param {Boolean} force force lock without checking the content inner status
     * @param {Boolean} [internal] true for internal call (do not add lock/unlock to the stack)
     * @private
     */
    _lockOrUnlockContent: function (content, lock, callback, force, internal)
    {
        if (this.destroyed != false)
        {
            return;
        }
        
        var contentId = content.getId();
        if (!this._lockedMap[contentId])
        {
            // Add content to the map of object to be lock/unlock
            this._lockedMap[contentId] = {
                state : Ametys.cms.content.EditContentsView._LOCK_STATE_IDLE,
                count : 0,
                actions : []
            }
        }
        
        // Add lock or unlock action to the stack (except for internal call)
        if (!internal)
        {
            this._lockedMap[contentId].actions.push({
                action : lock ? "lock" : 'unlock',
                callback : callback
            });
        }
        
        var count = this._lockedMap[contentId].count;
        var state = this._lockedMap[contentId].state;
        
        if (state == Ametys.cms.content.EditContentsView._LOCK_STATE_IDLE)
        {
            if (lock && count < 0)
            {
                var me = this,
                    remainingActions = [];
                
                // Invoke callback functions for all lock actions and keep unlock actions only
                Ext.Array.each(this._lockedMap[contentId].actions, function (action) {
                    if (action.action == "lock")
                    {
                        if (Ext.isFunction(action.callback))
                        {
                            action.callback(true);
                        }
                        me._lockedMap[contentId].count++;
                    }
                    else
                    {
                        remainingActions.push(action);
                    }
                });
                
                // Update remaining actions
                this._lockedMap[contentId].actions = remainingActions;
                if (this._lockedMap[contentId] && this._lockedMap[contentId].count == 0 && this._lockedMap[contentId].actions.length == 0)
                {
                    delete this._lockedMap[contentId];
                }
            }
            else
            {
                // Do lock or unlock action
                this._lockedMap[contentId].state = Ametys.cms.content.EditContentsView._LOCK_STATE_WORKING;
                if (lock)
                {
                    content.lock(Ext.bind(this._lockOrUnlockContentCb, this, [contentId, lock], true), force);
                }
                else
                {
                    content.unlock(Ext.bind(this._lockOrUnlockContentCb, this, [contentId, lock], true), force);
                }
            }
        }
    },
    
    /**
     * @private
     * Callback function invoked after locking or unlocking a content
     * @param {Boolean} success true if successfully changed lock state
     * @param {String} contentId the id of content being locked or unlock
     * @param {Boolean} lock true if the content was locked, false it was unlocked
     */
    _lockOrUnlockContentCb: function (success, contentId, lock)
    {
        if (this.destroyed != false)
        {
            return;
        }
        
        if (this._lockedMap[contentId])
        {
            this._lockedMap[contentId].state = Ametys.cms.content.EditContentsView._LOCK_STATE_IDLE;

            var me = this,
                remainingActions = [],
                tmpCount = this._lockedMap[contentId].count;
            
            // Invoke callback functions for same actions of the stack and keep invert actions
            Ext.Array.each(this._lockedMap[contentId].actions, function (action) {
                if ((lock && action.action == "lock") || (!lock && action.action == 'unlock'))
                {
                    if (Ext.isFunction(action.callback))
                    {
                        action.callback(success);
                    }
                    me._lockedMap[contentId].count = me._lockedMap[contentId].count + (lock ? 1 : -1);
                    tmpCount = tmpCount + (lock ? 1 : -1);
                }
                else
                {
                    remainingActions.push(action);
                    tmpCount = tmpCount + (lock ? -1 : 1);
                }
            });
                
            // Update remaining actions
            this._lockedMap[contentId].actions = remainingActions;
            
            // If there are invert actions to do (some nasty unlock came in play), let's do it
            if (!Ext.isEmpty(remainingActions))
            {
                if ((lock && tmpCount > 0) || (!lock && tmpCount < 0))
                {
                    this._lockedMap[contentId].actions = []; 
                    
                    Ext.Array.each(remainingActions, function (action) {
                        // just do callbacks
                        me._lockedMap[contentId].count = me._lockedMap[contentId].count + (lock ? -1 : 1)
                        if (Ext.isFunction(action))
                        {
                            action.callback(success); // FIXME with success ??
                        }
                    });
                }
                else
                {
                    var dummyContent = Ext.create("Ametys.cms.content.Content", { locked: lock, id: contentId, messageTarget: this.messageTarget });
                    this._lockOrUnlockContent(dummyContent, !lock, null, true, true);
                }
            }
            if (this._lockedMap[contentId] && this._lockedMap[contentId].count == 0 && this._lockedMap[contentId].actions.length == 0)
            {
                delete this._lockedMap[contentId];
            }
        }
    }
});