UNPKG

apostrophe

Version:

The Apostrophe Content Management System.

426 lines (365 loc) • 14.2 kB
apos.define('apostrophe-array-editor-modal', { extend: 'apostrophe-modal', source: 'arrayEditor', transition: 'slide', chooser: true, construct: function(self, options) { self.error = options.error; self.errorPath = options.errorPath; // This method initializes the array editor and triggers either the creation // or the editing of the first item. In the latter case the list of items // is also implicitly loaded after first generating the item titles for the // list view. This method is invoked for you by `afterShow` and should not be // invoked again. self.load = function() { self.$arrayItems = self.$el.find('[data-apos-array-items]'); self.$arrayItem = self.$el.find('[data-apos-array-item]'); self.field = options.field; self.arrayItems = options.arrayItems || []; self.originalArrayItems = _.cloneDeep(self.arrayItems); if (self.errorPath && self.errorPath[0]) { self.active = parseInt(self.errorPath[0]); } else { self.active = 0; } if (!self.arrayItems.length) { return self.createItem(); } else { self.setItemTitles(); return self.editItem(); } }; self.beforeCancel = function(callback) { // We must modify arrayItems in place, the calling code expects us // to modify that array by reference and won't understand if we // just reassign to the property self.arrayItems.splice(0, self.arrayItems.length); _.each(self.originalArrayItems, function(item) { self.arrayItems.push(item); }); return callback(null); }; // This method is now a bc placeholder self.generateTitle = function(item) { return 'placeholder'; }; // This method, called for you when the modal is about to display, // binds the various click handlers. self.beforeShow = function(callback) { self.bindClickHandlers(); self.addSaveHandler(); return callback(null); }; // This method removes the "save" click handler after the modal is hidden, // necessary to ensure it does not fire again when a parent array is saved. self.afterHide = function() { self.removeSaveHandler(); }; // This method installs the `saveHandler` method as the click handler for // the outer modal's "save" button. It is invoked for you during `beforeShow`. self.addSaveHandler = function() { self.$save = self.$el.parents('[data-modal]').find('[data-apos-save-array-items]'); self.$save.on('click', self.saveHandler); }; // This method removes the save button handler. It is invoked for you // during `afterHide`. self.removeSaveHandler = function() { self.$save.off('click', self.saveHandler); }; // This method saves the item state for the current item, then saves the // array as a whole and dismisses the modal via `hide`. It is invoked for // you when save is clicked. self.saveHandler = function() { self.saveItemState(function(err) { if (err) { return; } self.saveArray(); }); }; // This method invokes the `load` method to populate the array and // prepare for editing. It is invoked for you as the modal becomes visible. self.afterShow = function() { self.load(); }; // This method adds a new item to the array. It is invoked // by `createItem`, which should be called instead if your intention // is to immediately display the new item in the item editor. self.addToItems = function() { var item = apos.schemas.newInstance(self.field.schema); _.assign(item, { id: apos.utils.generateId() }); self.arrayItems.push(item); self.active = self.arrayItems.length - 1; self.refresh(); }; // This method sets the titles in the list view for all of the // current items by invoking the `generateTitle` method. self.nextOrdinal = 1; // this method is now a bc placeholder, except that it // is still responsible for setting `_ordinal` to a unique // counting number for each item that does not yet have one self.setItemTitles = function() { _.each(self.arrayItems, function(item, i) { if (!item._ordinal) { item._ordinal = self.nextOrdinal++; } }); }; // bc wrapper. This method was redundant. See `refresh` self.refreshItems = function() { return self.refresh(); }; // Adds a new item to the array, populating the form with its // initial default values from the schema. The list view is refreshed. self.createItem = function() { return self.html('arrayItem', { field: self.field }, function(html) { self.$arrayItem.html($(html)); self.addToItems(); self.populateItem(); }); }; // Like `createItem`, this method asks the server for a // empty form; however, it then populates it with the currently // active item's content. The list view is refreshed. self.editItem = function() { return self.html('arrayItem', { field: self.field }, function(html) { self.$arrayItem.html($(html)); self.populateItem(); }); }; // This method populates the editing form with the content of the // currently active item as determined by the array index `self.active`. // The list view is also refreshed. self.populateItem = function() { var $form = self.$arrayItem.find('[data-apos-form]'); apos.schemas.populate($form, self.field.schema, self.arrayItems[self.active], function(err) { if (err) { apos.utils.error(err); } self.refresh(); if (self.errorPath && (self.errorPath.length > 1)) { apos.schemas.returnToError($form, self.field.schema, self.errorPath.slice(1), self.error, function(err) { if (err) { apos.utils.error(err); } }); } }); }; // This method saves the content of the currently active item // back to `self.arrayItems[self.active]` by invoking `convert` for // the schema fields, and also updates the `_title` property for the // list view. self.saveItemState = function(callback) { if (self.active === -1) { // We currently don't have an actively selected item to convert. return setImmediate(callback); } apos.schemas.convert(self.$arrayItem.find('[data-apos-form]'), self.field.schema, self.arrayItems[self.active], function(err) { if (err) { return callback(err); } self.setItemTitles(); return callback(); }); }; // This method invokes `options.save` and passes `self.arrayItems` to it, // then dismisses the modal via `hide`. self.saveArray = function() { options.save(_.map(self.arrayItems, function(item) { return _.omit(item, '_ordinal'); })); self.hide(); }; // This method binds click handlers for all elements inside // `self.$el`, the modal itself. The save handler is bound elsewhere // because it may reside in a parent modal. self.bindClickHandlers = function() { self.$el.on('click', '[data-apos-array-items-trigger]', function(e) { e.stopPropagation(); e.preventDefault(); var $trigger = $(this); var index = $trigger.data('apos-array-items-trigger'); if (index === self.active) { // Nothing to be done, don't trigger scary schema error messages yet return; } self.saveItemState(function(err) { if (err) { return; } self.active = index; self.editItem(); }); }); if (options.field.readOnly) { // Remove "Add item" and "Save Items" button self.$el.children().find('[data-apos-add-array-item]').remove(); self.$el.parents('[data-modal]').find('[data-apos-save-array-items]').remove(); } else { self.$el.on('click', '[data-apos-add-array-item]', function(e) { e.stopPropagation(); e.preventDefault(); self.saveItemState(function(err) { if (err) { return; } self.createItem(); }); }); self.$el.on('click', '[data-apos-move-array-item]', function(e) { e.stopPropagation(); e.preventDefault(); var $el = $(this); self.saveItemState(function(err) { if (err) { return; } var id = $el.closest('[data-id]').attr('data-id'); var direction = $el.data('apos-move-array-item'); var index = _.findIndex(self.arrayItems, {id: id}); var newIndex; if (direction === 'up') { if (index <= 0) { return; } newIndex = index - 1; } else if (direction === 'down') { if (index >= self.arrayItems.length - 1) { return; } newIndex = index + 1; } var temp = self.arrayItems[index]; self.arrayItems[index] = self.arrayItems[newIndex]; self.arrayItems[newIndex] = temp; self.active = newIndex; self.editItem(); self.setItemTitles(); self.refresh(); }); }); self.$el.on('click', '[data-apos-delete-array-item]', function(e) { e.stopPropagation(); e.preventDefault(); var $self = $(this); var id = $self.closest('[data-id]').attr('data-id'); if (id) { self.remove(id); } // If there are no array items left, go blank if (!self.arrayItems.length) { self.active = -1; return self.$arrayItem.html(''); } // If we just removed the active array item, switch to the one // before this one. if (self.active === parseInt($self.closest('[data-apos-array-items-trigger]').attr('data-apos-array-items-trigger'))) { self.active = Math.max(parseInt(self.active) - 1, 0); self.editItem(); } // If an active item in an array is last and delete an any item above the last item // Then the active item of an array to be resetted to the last item if (self.active === self.arrayItems.length) { self.active = self.active - 1; } }); } }; // This method removes the item with the specified `id` property from the // array. self.remove = function(id) { self.arrayItems = _.filter(self.arrayItems, function(choice) { return choice.id !== id; }); return self.refresh(); }; self.refreshing = 0; self.last = []; // Refresh (re-render) the list of items, then invoke the `onChange` method // if they differ from the previous set. This method is debounced. If calls to // this method are nested only two refreshes will take place: the initial one // and also the last one, to ensure the impact of any changes made in nested // function calls is seen. As a further optimization, only the last one actually // updates the markup in the browser. // // If a callback is passed, it is always invoked, even if this // refresh is being skipped as an optimization due to nesting. self.refresh = function(callback) { if (self.refreshing) { self.refreshing++; return callback && callback(); } self.refreshing++; self.$arrayItems.html(''); return self.html('arrayItems', { active: self.active, arrayItems: self.arrayItems, field: self.field }, function(html) { // If calls are nested make sure only the last one actually updates the // markup, as a visual optimization if (self.refreshing === 1) { self.$arrayItems.html(html); } self.decrementRefreshing(); var compare = JSON.stringify(self.arrayItems); if (self.last !== compare) { self.last = compare; self.onChange(); } return callback && callback(); }, function(err) { apos.utils.error(err); self.decrementRefreshing(); return callback && callback(); }); }; // Invoked when the contents of the array have changed, after // a refresh of the display. Invokes the limit mechanism. self.onChange = function() { self.required(); self.limit(); }; // Implements the `limit` option by showing and hiding // the limit message and the add button, respectively. self.limit = function() { if (!self.field.limit) { return; } if (self.arrayItems.length >= self.field.limit) { self.$el.find('[data-apos-add-array-item]').hide(); self.$el.find('[data-apos-array-limit-reached]').show(); } else { self.$el.find('[data-apos-add-array-item]').show(); self.$el.find('[data-apos-array-limit-reached]').hide(); } }; // Implements the `required` option by showing and hiding // save button. self.required = function() { if (!self.field.required) { return; } if (self.arrayItems.length) { self.$save.removeAttr('disabled'); self.$save.removeClass('apos-button--disabled'); } else { self.$save.attr('disabled', ''); self.$save.addClass('apos-button--disabled'); } }; // Invoked by `refresh` to decrement the count of nested // `refresh` calls. Nested `refresh` calls are automatically // debounced for performance, however the first and last both // result in actual renders by the server to ensure all // changes made in between are reflected. self.decrementRefreshing = function() { self.refreshing--; // If one or more additional refreshes have been requested, carrying out // one more is sufficient if (self.refreshing > 0) { self.refreshing = 0; self.refresh(); } }; } });