jsharmony-cms
Version:
712 lines (595 loc) • 24.5 kB
JavaScript
/*
Copyright 2020 apHarmony
This file is part of jsHarmony.
jsHarmony is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
jsHarmony is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with this package. If not, see <http://www.gnu.org/licenses/>.
*/
var _ = require('lodash');
var Convert = require('../utils/convert');
var GridDataStore = require('./gridDataStore');
var DataEditor_Form = require('./dataEditor_form');
var TemplateRenderer = require('../templateRenderer');
/** @typedef {import('../componentModel/componentTemplate').ComponentTemplate} ComponentTemplate */
/** @typedef {import('../templateRenderer').RenderConfig} RenderConfig */
/** @typedef {import('../templateRenderer').GridPreviewRenderContext} GridPreviewRenderContext */
/** @typedef {import('../componentModel/dataModelTemplate_gridPreview').DataModelTemplate_GridPreview} DataModelTemplate_GridPreview */
/**
* @callback DataEditor_GridPreviewController~beforeRenderGridRow
* @param {DataGridItemEditor~RenderConfig} renderConfig
*/
/**
* @callback DataEditor_GridPreviewController~renderGridRow
* @param {HTMLElement} element
* @param {Object} data - the component data
* @param {Object} properties - the component properties
* @param {Object} cms - the parent jsHarmonyCMSInstance
* @param {Object} component - the parent component
*/
/**
* @class
* @classdesc Controller for handling grid preview data editor.
* @public
* @param {Object} xmodel
* @param {Object} data - the data used to render the component.
* @param {Object} properties - the component's configured properties (used to render the component)
* @param {(JQuery | HTMLElement)} dialogWrapper
* @param {Object} cms
* @param {Object} jsh
* @param {Object} component
* @param {DataModelTemplate_GridPreview} dataModelTemplate_GridPreview
* @param {ComponentTemplate} componentTemplate
*/
function DataEditor_GridPreviewController(xmodel, data, properties, dialogWrapper, cms, jsh, component, dataModelTemplate_GridPreview, componentTemplate) {
var _this = this;
/** @private @type {Object} */
this._properties = properties;
/** @private @type {Object} */
this.jsh = jsh;
/** @private @type {Object} */
this.cms = cms;
/** @private @type {Object} */
this.component = component;
/** @private @type {Object} */
this.xmodel = xmodel;
/** @private @type {JQuery} */
this.$dialogWrapper = this.jsh.$(dialogWrapper);
/** @private @type {string} */
this._idFieldName = dataModelTemplate_GridPreview.getIdFieldName();
/** @private @type {ComponentTemplate} */
this._componentTemplate = componentTemplate;
/** @private @type {string} */
this._rowTemplate = dataModelTemplate_GridPreview.getRowTemplate();
/** @private @type {DataModelTemplate_GridPreview} */
this._modelTemplate = dataModelTemplate_GridPreview;
/** @public @type {DataEditor_GridPreviewController~tileUpdateCallback} */
this.onDataUpdated = undefined;
/** @public @type {DataEditor_GridPreviewController~beforeRenderGridRow} */
this.onBeforeRenderGridRow = undefined;
/** @public @type {DataEditor_GridPreviewController~renderGridRow} */
this.onRenderGridRow = undefined;
if (!_.isArray(data || [])) {
throw new Error('Grid data must be an array');
}
data = data || [];
// We are going to keep two copies of data.
// The data store has clean and distinct references
// that the model doesn't touch.
// The data store is updated by the user and then
// changes are propagated to the API data which is
// attached to the grid.
/** @type {GridDataStore} */
this._dataStore = new GridDataStore(this._idFieldName);
this._apiData = [];
_.each(data, function(item, index) {
item[_this._idFieldName] = _this.makeItemId();
item.sequence = index;
// Don't expose references in data store.
// The grid is not allowed to touch them.
_this._dataStore.addNewItem(item);
_this._apiData.push(_.extend({}, item));
});
}
/**
* Called by JSH when adding a row.
* @public
* @param {object} $row - the JQuery row element proper
* @param {Object} rowData - the data for the row (augmented by model)
*/
DataEditor_GridPreviewController.prototype.addRow = function($row, rowData) {
var rowId = this.getParentRowId($row);
var $rowComponent = this.getRowElementFromRowId(rowId);
var _this = this;
$row.find('td.xgrid_action_cell.delete').remove();
if (rowData._is_insert) {
var id = this.makeItemId();
this._insertId = id;
rowData._insertId = id;
$rowComponent.attr('data-item-id', id);
setTimeout(function() {
_this.scrollToItemRow(id);
});
} else {
$rowComponent.attr('data-item-id', rowData[this._idFieldName]);
this.renderRow(rowData);
}
};
/**
* Change the position of the item in the item list.
* @private
* @param {number} itemId - the item ID of the item being moved
* @param {boolean} moveDown - set to true to mode the item toward the end of the list.
*/
DataEditor_GridPreviewController.prototype.changeItemSequence = function(itemId, moveDown) {
var item = this._dataStore.getDataItem(itemId);
if (!item) return;
var items = this._dataStore.getDataArray() || [];
item.sequence = item.sequence == undefined ? 9999 : Convert.toNumber(item.sequence);
// Find the adjacent items
var prevSequence = -1;
var prevIndex = -1;
var nextSequence = 9999;
var nextIndex = -1;
for (var i = 0; i < items.length; i++) {
var currentItem = items[i];
var sequence = Convert.toNumber(currentItem.sequence);
// Find the previous adjacent item by finding the max sequence
// less than the current sequence
if (sequence < item.sequence && sequence > prevSequence) {
prevSequence = sequence;
prevIndex = i;
}
// Find the next adjacent item by finding the min sequence
// greater than the current sequence
if (sequence > item.sequence && sequence < nextSequence) {
nextSequence = sequence;
nextIndex = i;
}
}
var doUpdate = false;
var updateIndex = -1;
var newSequence = -1;
if (moveDown && nextIndex > -1) {
updateIndex = nextIndex;
newSequence = nextSequence;
doUpdate = true;
} else if (!moveDown && prevIndex > - 1) {
updateIndex = prevIndex;
newSequence = prevSequence;
doUpdate = true;
}
if (doUpdate) {
var adjData = items[updateIndex];
adjData.sequence = item.sequence;
this.updateModelDataFromDataStore(this.getRowIdFromItemId(adjData[this._idFieldName]));
item.sequence = newSequence;
this.updateModelDataFromDataStore(this.getRowIdFromItemId(item[this._idFieldName]));
this._dataStore.sortBySequence();
this._apiData.splice(0, this._apiData.length);
// Update the data attached to the grid from the pristine data store
this._dataStore.getDataArray().forEach(a => this._apiData.push(a));
// Data was changed in the view
this.dataUpdated();
// A refresh is required by the current grid
// system to ensure rows are re-drawn in correct order.
this.forceRefresh();
}
};
/**
* Call anytime slide data is changed (and valid) in the view.
* @private
*/
DataEditor_GridPreviewController.prototype.dataUpdated = function() {
this.updateParentController();
};
/**
* Commit the data in the grid.
* Should only be used for inserts and deletes.
* @private
*/
DataEditor_GridPreviewController.prototype.forceCommit = function() {
var controller = this.xmodel.controller;
controller.editablegrid.CurrentCell = undefined;
controller.Commit();
};
DataEditor_GridPreviewController.prototype.showOverlay = function() {
var joverlay = this.$dialogWrapper.find('.refreshLoadingOverlay');
if(joverlay.length){
joverlay.stop(true);
joverlay.show();
joverlay.css('opacity', 1);
}
else {
this.$dialogWrapper.append('<div class="refreshLoadingOverlay" style="opacity:1;position:absolute;top:0px;left:0px;width:100%;height:'+this.$dialogWrapper[0].scrollHeight+'px;background-color:white;z-index:2147483639;"></div>');
}
};
DataEditor_GridPreviewController.prototype.hideOverlay = function() {
var _this = this;
this.$dialogWrapper.find('.refreshLoadingOverlay').stop().fadeOut(function(){
_this.jsh.$(this).remove();
});
};
/**
* Refresh the grid.
* @private
*/
DataEditor_GridPreviewController.prototype.forceRefresh = function(cb) {
// Need to maintain the scroll position
// after the grid re-renders
var _this = this;
var scrollTop = _this.$dialogWrapper.scrollTop();
//Show overlay
_this.showOverlay();
var controller = _this.xmodel.controller;
controller.editablegrid.CurrentCell = undefined;
controller.grid.Load(undefined, undefined, function(){
if(cb){
if(cb()===false){ //Do not hide overlay
_this.$dialogWrapper.scrollTop(scrollTop);
return;
}
}
_this.hideOverlay();
_this.$dialogWrapper.scrollTop(scrollTop);
});
};
/**
* @private
* @param {string} itemId
* @returns {GridPreviewRenderContext}
*/
DataEditor_GridPreviewController.prototype.getGridPreviewRenderContext = function(itemId) {
var itemIndex = itemId ? this._dataStore.getItemIndexById(itemId) : this._dataStore.count();
/** @type {GridPreviewRenderContext} */
var retVal = {
rowIndex: itemIndex
};
return retVal;
};
/**
* Get the item data for the corresponding rowId
* @private
* @param {number} rowId - the row ID of the data to get.
* @return {(Oobject | undefined)}
*/
DataEditor_GridPreviewController.prototype.getItemDataFromRowId = function(rowId) {
var slideId = this.jsh.$('.xrow.xrow_' + this.xmodel.id + '[data-id="' + rowId + '"] [data-component-template="gridRow"]')
.attr('data-item-id');
return this._dataStore.getDataItem(slideId) || {};
};
/**
* Get the next item sequence which is equal to the
* current max sequence + 1.
* @private
* @returns {number}
*/
DataEditor_GridPreviewController.prototype.getNextSequenceNumber = function() {
var maxItem = _.maxBy(this._dataStore.getDataArray(), function(item) {
return _.isNumber(item.sequence) ? item.sequence : -1;
});
if(!maxItem) return 1;
return _.isNumber(maxItem.sequence) ? maxItem.sequence + 1 : 0;
};
/**
* Get the row ID of the parent row for the given element.
* @private
* @param {object} $element - a child JQuery element of the row
* @return {number}
*/
DataEditor_GridPreviewController.prototype.getParentRowId = function($element) {
return this.jsh.XExt.XModel.GetRowID(this.xmodel.id, $element);
};
/**
* Find the topmost element defined in the row template for the row
* with the given ID.
* @private
* @param {number} rowId
* @returns {JQuery}
*/
DataEditor_GridPreviewController.prototype.getRowElementFromRowId = function(rowId) {
var rowSelector = '.xrow[data-id="' + rowId + '"]';
return this.$dialogWrapper.find(rowSelector + ' [data-component-template="gridRow"]');
};
/**
* Get the row ID for the item with the given ID.
* @private
* @param {number} itemId - the item ID to use to find the parent row ID.
* @return {number}
*/
DataEditor_GridPreviewController.prototype.getRowIdFromItemId = function(itemId) {
var $el = this.jsh.$(this.$dialogWrapper).find('[data-component-template="gridRow"][data-item-id="' + itemId + '"]');
return this.getParentRowId($el);
};
/**
* Entry point for controller. Do not call until
* the form is on screen.
* @public
*/
DataEditor_GridPreviewController.prototype.initialize = function() {
var _this = this;
var modelInterface = this.jsh.App[this.xmodel.id];
if (!_.isFunction(modelInterface.getDataApi)) {
throw new Error('model must have function "getDataApi(xmodel, apiType)"');
}
var gridApi = modelInterface.getDataApi(this.xmodel, 'grid');
var formApi = modelInterface.getDataApi(this.xmodel, 'form');
gridApi.dataset = this._apiData;
formApi.dataset = this._apiData;
formApi.onInsert = function(action, actionResult, newRow) {
newRow[_this._idFieldName] = _this._insertId;
newRow.sequence = _this.getNextSequenceNumber();
_this._insertId = undefined;
var dataStoreItem = _this._modelTemplate.makePristineCopy(newRow, false);
dataStoreItem = _this._modelTemplate.populateDataInstance(dataStoreItem);
_this._dataStore.addNewItem(dataStoreItem);
actionResult[_this.xmodel.id] = {};
actionResult[_this.xmodel.id][_this._idFieldName] = newRow[_this._idFieldName];
_this.dataUpdated();
_this.renderRow(_this._dataStore.getDataItem(newRow[_this._idFieldName]));
};
formApi.onDelete = function(action, actionResult, keys) {
_this.showOverlay();
_this._dataStore.deleteItem(keys[_this._idFieldName]);
//Commit Data
_this._apiData.splice(0, _this._apiData.length);
_this._dataStore.getDataArray().forEach(a => _this._apiData.push(a));
_this.dataUpdated();
return false;
};
this.forceRefresh();
};
/**
* Check to see if the component is readonly.
* @private
* @returns {boolean} - true if the model is readonly.
*/
DataEditor_GridPreviewController.prototype.isReadOnly = function() {
return !!this.cms.readonly;
};
/**
* Create a random item id
* @private
* @returns {string}
*/
DataEditor_GridPreviewController.prototype.makeItemId = function() {
return '_' + Math.random().toString().replace('.', '');
};
/**
* @private
*/
DataEditor_GridPreviewController.prototype.addItem = function() {
var _this = this;
if (_this.xmodel.controller.editablegrid.CurrentCell) if(!_this.xmodel.controller.form.CommitRow()) return;
if (_this.jsh.XPage.GetChanges().length > 0) { _this.jsh.XExt.Alert('Please save all changes before adding a row.'); return; }
var dataEditor = new DataEditor_Form(this._componentTemplate, this.getGridPreviewRenderContext(null), this.isReadOnly(), this.cms, this.jsh, _this.component);
//Create a new item
var currentData = this.xmodel.controller.form.NewRow({ unbound: true });
//Open the form to edit the item
dataEditor.open(currentData, this._properties || {}, function(updatedData) {
_.assign(currentData, updatedData);
var rowId = _this.xmodel.controller.AddRow();
for(var key in currentData){
if(key in _this.xmodel.fields){
var oldval = _this.xmodel.get(key, rowId);
if(oldval !== currentData[key]){
_this.xmodel.set(key, updatedData[key], rowId);
}
}
}
_this.updateModelDataFromDataStore(rowId);
_this.dataUpdated();
_this.forceCommit();
_this.renderRow(currentData);
});
};
/**
* @private
* @param {string} itemId - the ID of the item to edit
*/
DataEditor_GridPreviewController.prototype.openItemEditor = function(itemId) {
var _this = this;
var dataEditor = new DataEditor_Form(this._componentTemplate, this.getGridPreviewRenderContext(itemId), this.isReadOnly(), this.cms, this.jsh, _this.component);
var currentData = this._dataStore.getDataItem(itemId);
dataEditor.open(currentData, this._properties || {}, function(updatedData) {
_.assign(currentData, updatedData);
var dataId = currentData[_this._idFieldName];
var rowId = _this.getRowIdFromItemId(dataId);
for(var key in currentData){
if(key in _this.xmodel.fields){
var oldval = _this.xmodel.get(key, rowId);
if(oldval !== currentData[key]){
_this.xmodel.set(key, updatedData[key], rowId);
}
}
}
_this.updateModelDataFromDataStore(rowId);
_this.dataUpdated();
_this.renderRow(currentData);
}, function() {
_this.scrollToItemRow(itemId);
});
};
/**
* Prompt to delete the row with the given row ID
* @private
* @param {number} rowId - the ID of the row to delete.
*/
DataEditor_GridPreviewController.prototype.promptDelete = function(rowId) {
var _this = this;
_this.jsh.XExt.Confirm('Are you sure you want to delete this item?', function(){
//Perform Delete
_this.xmodel.controller.DeleteRow(rowId, { force: true });
setTimeout(function() {
_this.forceCommit();
setTimeout(function() { _this.forceRefresh(); });
});
});
};
/**
* Render the row defined by the data
* @override
* @private
* @param {TileData} data
*/
DataEditor_GridPreviewController.prototype.renderRow = function(data) {
var _this = this;
var dataId = data[this._idFieldName];
var rowId = this.getRowIdFromItemId(dataId);
var $row = this.getRowElementFromRowId(rowId);
var componentConfig = this._componentTemplate && this._componentTemplate._componentConfig;
var template =
'<div class="jsharmony_cms component_toolbar">' +
'<div class="component_toolbar_button" data-component-part="moveItem" data-dir="prev">' +
'<span class="material-icons" style="display:inline-block; transform: rotate(-90deg)">' +
'chevron_right' +
'</span>' +
'</div>' +
'<div class="component_toolbar_button" data-component-part="moveItem" data-dir="next">' +
'<span class="material-icons" style="display:inline-block; transform: rotate(90deg)">' +
'chevron_right' +
'</span>' +
'</div>' +
'<div class="component_toolbar_button" data-component-part="editButton" data-allowReadOnly>' +
'<span class="material-icons">' +
'edit' +
'</span>' +
'</div>' +
'<div class="component_toolbar_button" data-component-part="deleteItem">' +
'<span class="material-icons">' +
'delete' +
'</span>' +
'</div>' +
'</div>' +
'<div class="jsharmony_cms_component_preview" data-component-part="preview"></div>';
$row.empty().append(template);
var renderConfig = TemplateRenderer.createRenderConfig(this._rowTemplate, { items: [data] }, this._properties || {}, this.cms);
renderConfig.gridContext = this.getGridPreviewRenderContext(dataId);
if (_.isFunction(this.onBeforeRenderGridRow)) this.onBeforeRenderGridRow(renderConfig);
var rendered = TemplateRenderer.render(renderConfig, 'gridRowDataPreview', this.jsh, this.cms, componentConfig);
var $wrapper = $row.find('[data-component-part="preview"]');
$wrapper.empty().append(rendered);
if(this.cms && this.cms.editor) this.cms.editor.disableLinks($wrapper);
if (this.isReadOnly()) {
$row.find('.component_toolbar_button:not([data-allowReadOnly])').attr('disabled', true);
} else {
$row.find('[data-component-part="moveItem"]').off('click.basicComponent').on('click.basicComponent', function(e) {
if (_this.isReadOnly()) return;
var moveDown = _this.jsh.$(e.target).closest('.component_toolbar_button[data-dir]').attr('data-dir') === 'next';
_this.changeItemSequence(dataId, moveDown);
});
$row.find('[data-component-part="deleteItem"]').off('click.basicComponent').on('click.basicComponent', function(e) {
if (_this.isReadOnly()) return;
var rowId = _this.getParentRowId(e.target);
_this.promptDelete(rowId);
});
}
$row.find('[data-component-part="editButton"]').on('click', function() {
_this.openItemEditor(dataId);
});
$row.find('[data-component-part="preview"]').off('dblclick.cmsComponent').on('dblclick.cmsComponent', function() {
_this.openItemEditor(dataId);
});
$row.off('mousedown.cmsComponent').on('mousedown.cmsComponent', function(event) {
// We don't want the user to accidentally select text (which happens often)
// when double clicking. This will prevent that.
if (event.detail === 2) {
event.preventDefault();
}
});
this.updateSequenceButtonViews();
if (_.isFunction(this.onRenderGridRow)) this.onRenderGridRow($row.find('[data-component-part="preview"]')[0], renderConfig.data, renderConfig.properties, _this.cms, _this.component);
_this.component.notifyUpdate($row.find('[data-component-part="preview"]')[0], {
data: renderConfig.data,
properties: renderConfig.properties,
isGridRowPreview: true,
});
setTimeout(function() {
_.forEach($row.find('[data-component-part="preview"] [data-component]'), function(el) {
_this.cms.componentManager.renderContentComponent(el);
});
}, 100);
};
/**
* Set the modal scroll position to show the row for the
* item with the given item ID. If the item is already visible
* then do nothing.
* @private
* @param {string} itemId
*/
DataEditor_GridPreviewController.prototype.scrollToItemRow = function(itemId) {
var $row = this.getRowElementFromRowId(this.getRowIdFromItemId(itemId));
if ($row.length < 1 ) return;
var $scrollParent = $row.scrollParent();
var scrollParentY = $scrollParent.offset().top;
var rowRelativeStartY = $row.offset().top - scrollParentY;
var rowRelativeEndY = rowRelativeStartY + $row.outerHeight();
var parentRelativeMaxY = $scrollParent.height();
var isRowFullyInView = rowRelativeStartY >= 0 && rowRelativeEndY <= parentRelativeMaxY;
if (isRowFullyInView) return;
var rowFitsInView = (rowRelativeEndY - rowRelativeStartY) < parentRelativeMaxY;
if (!rowFitsInView) {
// If the row doesn't fit then just scroll to the top of the row
$row[0].scrollIntoView();
return;
}
// Try to minimize the scroll distance
var rowTopDistanceFromParentTop = Math.abs(rowRelativeStartY);
var rowBottomDistanceFromParentBottom = Math.abs(parentRelativeMaxY - rowRelativeEndY);
var alignTop = rowTopDistanceFromParentTop <= rowBottomDistanceFromParentBottom;
$row[0].scrollIntoView(alignTop);
};
/**
* Copy properties from data store item to controller data item.
* Call anytime the data store item changes.
* @private
* @param {number} rowId - the ID of the row for which the corresponding data will be updated (mutated).
*/
DataEditor_GridPreviewController.prototype.updateModelDataFromDataStore = function(rowId) {
var idField = this._idFieldName;
var data = this.getItemDataFromRowId(rowId);
var item = this.xmodel.controller.form.DataSet.find(a => a[idField] === data[idField]);
if (!item) {
return;
}
// Don't share references!
_.extend(item, data);
this.xmodel.controller.form.ResetDirty();
};
/**
* Send updated data to the parent controller.
* Call anytime item data is changed (and valid).
* @private
*/
DataEditor_GridPreviewController.prototype.updateParentController = function() {
var _this = this;
this._dataStore.sortBySequence();
var items = this._dataStore.getDataArray() || [];
items = _.map(items, function(item) { return _this._modelTemplate.makePristineCopy(item, true); });
var data = { items: items };
if (_.isFunction(this.onDataUpdated)) this.onDataUpdated(data);
};
/**
* Iterate through data and enable/disable sequence buttons as needed.
* @private
*/
DataEditor_GridPreviewController.prototype.updateSequenceButtonViews = function() {
var _this = this;
_.forEach(this._dataStore.getDataArray(), function(item, index) {
var dataId = item[_this._idFieldName];
var $row = _this.getRowElementFromRowId(_this.getRowIdFromItemId(dataId));
var isFirst = index < 1;
var isLast = index >= (_this._dataStore.count() - 1);
$row.find('[data-component-part="moveItem"][data-dir="prev"]')
.attr('disabled', isFirst || _this.isReadOnly());
$row.find('[data-component-part="moveItem"][data-dir="next"]')
.attr('disabled', isLast || _this.isReadOnly());
});
};
exports = module.exports = DataEditor_GridPreviewController;