/** * @file * A Backbone Model for the state of an in-place editable entity in the DOM. */ (function (_, $, Backbone, Drupal) { Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.EntityModel# */{ /** * @type {object} */ defaults: /** @lends Drupal.quickedit.EntityModel# */{ /** * The DOM element that represents this entity. * * It may seem bizarre to have a DOM element in a Backbone Model, but we * need to be able to map entities in the DOM to EntityModels in memory. * * @type {HTMLElement} */ el: null, /** * An entity ID, of the form `/` * * @example * "node/1" * * @type {string} */ entityID: null, /** * An entity instance ID. * * The first instance of a specific entity (i.e. with a given entity ID) * is assigned 0, the second 1, and so on. * * @type {number} */ entityInstanceID: null, /** * The unique ID of this entity instance on the page, of the form * `/[entity instance ID]` * * @example * "node/1[0]" * * @type {string} */ id: null, /** * The label of the entity. * * @type {string} */ label: null, /** * A FieldCollection for all fields of the entity. * * @type {Drupal.quickedit.FieldCollection} * * @see Drupal.quickedit.FieldCollection */ fields: null, // The attributes below are stateful. The ones above will never change // during the life of a EntityModel instance. /** * Indicates whether this entity is currently being edited in-place. * * @type {bool} */ isActive: false, /** * Whether one or more fields are already been stored in PrivateTempStore. * * @type {bool} */ inTempStore: false, /** * Indicates whether a "Save" button is necessary or not. * * Whether one or more fields have already been stored in PrivateTempStore * *or* the field that's currently being edited is in the 'changed' or a * later state. * * @type {bool} */ isDirty: false, /** * Whether the request to the server has been made to commit this entity. * * Used to prevent multiple such requests. * * @type {bool} */ isCommitting: false, /** * The current processing state of an entity. * * @type {string} */ state: 'closed', /** * IDs of fields whose new values have been stored in PrivateTempStore. * * We must store this on the EntityModel as well (even though it already * is on the FieldModel) because when a field is rerendered, its * FieldModel is destroyed and this allows us to transition it back to * the proper state. * * @type {Array.} */ fieldsInTempStore: [], /** * A flag the tells the application that this EntityModel must be reloaded * in order to restore the original values to its fields in the client. * * @type {bool} */ reload: false, }, /** * @constructs * * @augments Drupal.quickedit.BaseModel */ initialize() { this.set('fields', new Drupal.quickedit.FieldCollection()); // Respond to entity state changes. this.listenTo(this, 'change:state', this.stateChange); // The state of the entity is largely dependent on the state of its // fields. this.listenTo(this.get('fields'), 'change:state', this.fieldStateChange); // Call Drupal.quickedit.BaseModel's initialize() method. Drupal.quickedit.BaseModel.prototype.initialize.call(this); }, /** * Updates FieldModels' states when an EntityModel change occurs. * * @param {Drupal.quickedit.EntityModel} entityModel * The entity model * @param {string} state * The state of the associated entity. One of * {@link Drupal.quickedit.EntityModel.states}. * @param {object} options * Options for the entity model. */ stateChange(entityModel, state, options) { const to = state; switch (to) { case 'closed': this.set({ isActive: false, inTempStore: false, isDirty: false, }); break; case 'launching': break; case 'opening': // Set the fields to candidate state. entityModel.get('fields').each((fieldModel) => { fieldModel.set('state', 'candidate', options); }); break; case 'opened': // The entity is now ready for editing! this.set('isActive', true); break; case 'committing': { // The user indicated they want to save the entity. const fields = this.get('fields'); // For fields that are in an active state, transition them to // candidate. fields.chain() .filter(fieldModel => _.intersection([fieldModel.get('state')], ['active']).length) .each((fieldModel) => { fieldModel.set('state', 'candidate'); }); // For fields that are in a changed state, field values must first be // stored in PrivateTempStore. fields.chain() .filter(fieldModel => _.intersection([fieldModel.get('state')], Drupal.quickedit.app.changedFieldStates).length) .each((fieldModel) => { fieldModel.set('state', 'saving'); }); break; } case 'deactivating': { const changedFields = this.get('fields') .filter(fieldModel => _.intersection([fieldModel.get('state')], ['changed', 'invalid']).length); // If the entity contains unconfirmed or unsaved changes, return the // entity to an opened state and ask the user if they would like to // save the changes or discard the changes. // 1. One of the fields is in a changed state. The changed field // might just be a change in the client or it might have been saved // to tempstore. // 2. The saved flag is empty and the confirmed flag is empty. If // the entity has been saved to the server, the fields changed in // the client are irrelevant. If the changes are confirmed, then // proceed to set the fields to candidate state. if ((changedFields.length || this.get('fieldsInTempStore').length) && (!options.saved && !options.confirmed)) { // Cancel deactivation until the user confirms save or discard. this.set('state', 'opened', { confirming: true }); // An action in reaction to state change must be deferred. _.defer(() => { Drupal.quickedit.app.confirmEntityDeactivation(entityModel); }); } else { const invalidFields = this.get('fields') .filter(fieldModel => _.intersection([fieldModel.get('state')], ['invalid']).length); // Indicate if this EntityModel needs to be reloaded in order to // restore the original values of its fields. entityModel.set('reload', (this.get('fieldsInTempStore').length || invalidFields.length)); // Set all fields to the 'candidate' state. A changed field may have // to go through confirmation first. entityModel.get('fields').each((fieldModel) => { // If the field is already in the candidate state, trigger a // change event so that the entityModel can move to the next state // in deactivation. if (_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) { fieldModel.trigger('change:state', fieldModel, fieldModel.get('state'), options); } else { fieldModel.set('state', 'candidate', options); } }); } break; } case 'closing': // Set all fields to the 'inactive' state. options.reason = 'stop'; this.get('fields').each((fieldModel) => { fieldModel.set({ inTempStore: false, state: 'inactive', }, options); }); break; } }, /** * Updates a Field and Entity model's "inTempStore" when appropriate. * * Helper function. * * @param {Drupal.quickedit.EntityModel} entityModel * The model of the entity for which a field's state attribute has * changed. * @param {Drupal.quickedit.FieldModel} fieldModel * The model of the field whose state attribute has changed. * * @see Drupal.quickedit.EntityModel#fieldStateChange */ _updateInTempStoreAttributes(entityModel, fieldModel) { const current = fieldModel.get('state'); const previous = fieldModel.previous('state'); let fieldsInTempStore = entityModel.get('fieldsInTempStore'); // If the fieldModel changed to the 'saved' state: remember that this // field was saved to PrivateTempStore. if (current === 'saved') { // Mark the entity as saved in PrivateTempStore, so that we can pass the // proper "reset PrivateTempStore" boolean value when communicating with // the server. entityModel.set('inTempStore', true); // Mark the field as saved in PrivateTempStore, so that visual // indicators signifying just that may be rendered. fieldModel.set('inTempStore', true); // Remember that this field is in PrivateTempStore, restore when // rerendered. fieldsInTempStore.push(fieldModel.get('fieldID')); fieldsInTempStore = _.uniq(fieldsInTempStore); entityModel.set('fieldsInTempStore', fieldsInTempStore); } // If the fieldModel changed to the 'candidate' state from the // 'inactive' state, then this is a field for this entity that got // rerendered. Restore its previous 'inTempStore' attribute value. else if (current === 'candidate' && previous === 'inactive') { fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0); } }, /** * Reacts to state changes in this entity's fields. * * @param {Drupal.quickedit.FieldModel} fieldModel * The model of the field whose state attribute changed. * @param {string} state * The state of the associated field. One of * {@link Drupal.quickedit.FieldModel.states}. */ fieldStateChange(fieldModel, state) { const entityModel = this; const fieldState = state; // Switch on the entityModel state. // The EntityModel responds to FieldModel state changes as a function of // its state. For example, a field switching back to 'candidate' state // when its entity is in the 'opened' state has no effect on the entity. // But that same switch back to 'candidate' state of a field when the // entity is in the 'committing' state might allow the entity to proceed // with the commit flow. switch (this.get('state')) { case 'closed': case 'launching': // It should be impossible to reach these: fields can't change state // while the entity is closed or still launching. break; case 'opening': // We must change the entity to the 'opened' state, but it must first // be confirmed that all of its fieldModels have transitioned to the // 'candidate' state. // We do this here, because this is called every time a fieldModel // changes state, hence each time this is called, we get closer to the // goal of having all fieldModels in the 'candidate' state. // A state change in reaction to another state change must be // deferred. _.defer(() => { entityModel.set('state', 'opened', { 'accept-field-states': Drupal.quickedit.app.readyFieldStates, }); }); break; case 'opened': // Set the isDirty attribute when appropriate so that it is known when // to display the "Save" button in the entity toolbar. // Note that once a field has been changed, there's no way to discard // that change, hence it will have to be saved into PrivateTempStore, // or the in-place editing of this field will have to be stopped // completely. In other words: once any field enters the 'changed' // field, then for the remainder of the in-place editing session, the // entity is by definition dirty. if (fieldState === 'changed') { entityModel.set('isDirty', true); } else { this._updateInTempStoreAttributes(entityModel, fieldModel); } break; case 'committing': { // If the field save returned a validation error, set the state of the // entity back to 'opened'. if (fieldState === 'invalid') { // A state change in reaction to another state change must be // deferred. _.defer(() => { entityModel.set('state', 'opened', { reason: 'invalid' }); }); } else { this._updateInTempStoreAttributes(entityModel, fieldModel); } // Attempt to save the entity. If the entity's fields are not yet all // in a ready state, the save will not be processed. const options = { 'accept-field-states': Drupal.quickedit.app.readyFieldStates, }; if (entityModel.set('isCommitting', true, options)) { entityModel.save({ success() { entityModel.set({ state: 'deactivating', isCommitting: false, }, { saved: true }); }, error() { // Reset the "isCommitting" mutex. entityModel.set('isCommitting', false); // Change the state back to "opened", to allow the user to hit // the "Save" button again. entityModel.set('state', 'opened', { reason: 'networkerror' }); // Show a modal to inform the user of the network error. const message = Drupal.t('Your changes to @entity-title could not be saved, either due to a website problem or a network connection problem.
Please try again.', { '@entity-title': entityModel.get('label') }); Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message); }, }); } break; } case 'deactivating': // When setting the entity to 'closing', require that all fieldModels // are in either the 'candidate' or 'highlighted' state. // A state change in reaction to another state change must be // deferred. _.defer(() => { entityModel.set('state', 'closing', { 'accept-field-states': Drupal.quickedit.app.readyFieldStates, }); }); break; case 'closing': // When setting the entity to 'closed', require that all fieldModels // are in the 'inactive' state. // A state change in reaction to another state change must be // deferred. _.defer(() => { entityModel.set('state', 'closed', { 'accept-field-states': ['inactive'], }); }); break; } }, /** * Fires an AJAX request to the REST save URL for an entity. * * @param {object} options * An object of options that contains: * @param {function} [options.success] * A function to invoke if the entity is successfully saved. */ save(options) { const entityModel = this; // Create a Drupal.ajax instance to save the entity. const entitySaverAjax = Drupal.ajax({ url: Drupal.url(`quickedit/entity/${entityModel.get('entityID')}`), error() { // Let the Drupal.quickedit.EntityModel Backbone model's error() // method handle errors. options.error.call(entityModel); }, }); // Entity saved successfully. entitySaverAjax.commands.quickeditEntitySaved = function (ajax, response, status) { // All fields have been moved from PrivateTempStore to permanent // storage, update the "inTempStore" attribute on FieldModels, on the // EntityModel and clear EntityModel's "fieldInTempStore" attribute. entityModel.get('fields').each((fieldModel) => { fieldModel.set('inTempStore', false); }); entityModel.set('inTempStore', false); entityModel.set('fieldsInTempStore', []); // Invoke the optional success callback. if (options.success) { options.success.call(entityModel); } }; // Trigger the AJAX request, which will will return the // quickeditEntitySaved AJAX command to which we then react. entitySaverAjax.execute(); }, /** * Validate the entity model. * * @param {object} attrs * The attributes changes in the save or set call. * @param {object} options * An object with the following option: * @param {string} [options.reason] * A string that conveys a particular reason to allow for an exceptional * state change. * @param {Array} options.accept-field-states * An array of strings that represent field states that the entities must * be in to validate. For example, if `accept-field-states` is * `['candidate', 'highlighted']`, then all the fields of the entity must * be in either of these two states for the save or set call to * validate and proceed. * * @return {string} * A string to say something about the state of the entity model. */ validate(attrs, options) { const acceptedFieldStates = options['accept-field-states'] || []; // Validate state change. const currentState = this.get('state'); const nextState = attrs.state; if (currentState !== nextState) { // Ensure it's a valid state. if (_.indexOf(this.constructor.states, nextState) === -1) { return `"${nextState}" is an invalid state`; } // Ensure it's a state change that is allowed. // Check if the acceptStateChange function accepts it. if (!this._acceptStateChange(currentState, nextState, options)) { return 'state change not accepted'; } // If that function accepts it, then ensure all fields are also in an // acceptable state. else if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) { return 'state change not accepted because fields are not in acceptable state'; } } // Validate setting isCommitting = true. const currentIsCommitting = this.get('isCommitting'); const nextIsCommitting = attrs.isCommitting; if (currentIsCommitting === false && nextIsCommitting === true) { if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) { return 'isCommitting change not accepted because fields are not in acceptable state'; } } else if (currentIsCommitting === true && nextIsCommitting === true) { return 'isCommitting is a mutex, hence only changes are allowed'; } }, /** * Checks if a state change can be accepted. * * @param {string} from * From state. * @param {string} to * To state. * @param {object} context * Context for the check. * @param {string} context.reason * The reason for the state change. * @param {bool} context.confirming * Whether context is confirming or not. * * @return {bool} * Whether the state change is accepted or not. * * @see Drupal.quickedit.AppView#acceptEditorStateChange */ _acceptStateChange(from, to, context) { let accept = true; // In general, enforce the states sequence. Disallow going back from a // "later" state to an "earlier" state, except in explicitly allowed // cases. if (!this.constructor.followsStateSequence(from, to)) { accept = false; // Allow: closing -> closed. // Necessary to stop editing an entity. if (from === 'closing' && to === 'closed') { accept = true; } // Allow: committing -> opened. // Necessary to be able to correct an invalid field, or to hit the // "Save" button again after a server/network error. else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) { accept = true; } // Allow: deactivating -> opened. // Necessary to be able to confirm changes with the user. else if (from === 'deactivating' && to === 'opened' && context.confirming) { accept = true; } // Allow: opened -> deactivating. // Necessary to be able to stop editing. else if (from === 'opened' && to === 'deactivating' && context.confirmed) { accept = true; } } return accept; }, /** * Checks if fields have acceptable states. * * @param {Array} acceptedFieldStates * An array of acceptable field states to check for. * * @return {bool} * Whether the fields have an acceptable state. * * @see Drupal.quickedit.EntityModel#validate */ _fieldsHaveAcceptableStates(acceptedFieldStates) { let accept = true; // If no acceptable field states are provided, assume all field states are // acceptable. We want to let validation pass as a default and only // check validity on calls to set that explicitly request it. if (acceptedFieldStates.length > 0) { const fieldStates = this.get('fields').pluck('state') || []; // If not all fields are in one of the accepted field states, then we // still can't allow this state change. if (_.difference(fieldStates, acceptedFieldStates).length) { accept = false; } } return accept; }, /** * Destroys the entity model. * * @param {object} options * Options for the entity model. */ destroy(options) { Drupal.quickedit.BaseModel.prototype.destroy.call(this, options); this.stopListening(); // Destroy all fields of this entity. this.get('fields').reset(); }, /** * @inheritdoc */ sync() { // We don't use REST updates to sync. }, }, /** @lends Drupal.quickedit.EntityModel */{ /** * Sequence of all possible states an entity can be in during quickediting. * * @type {Array.} */ states: [ // Initial state, like field's 'inactive' OR the user has just finished // in-place editing this entity. // - Trigger: none (initial) or EntityModel (finished). // - Expected behavior: (when not initial state): tear down // EntityToolbarView, in-place editors and related views. 'closed', // User has activated in-place editing of this entity. // - Trigger: user. // - Expected behavior: the EntityToolbarView is gets set up, in-place // editors (EditorViews) and related views for this entity's fields are // set up. Upon completion of those, the state is changed to 'opening'. 'launching', // Launching has finished. // - Trigger: application. // - Guarantees: in-place editors ready for use, all entity and field // views have been set up, all fields are in the 'inactive' state. // - Expected behavior: all fields are changed to the 'candidate' state // and once this is completed, the entity state will be changed to // 'opened'. 'opening', // Opening has finished. // - Trigger: EntityModel. // - Guarantees: see 'opening', all fields are in the 'candidate' state. // - Expected behavior: the user is able to actually use in-place editing. 'opened', // User has clicked the 'Save' button (and has thus changed at least one // field). // - Trigger: user. // - Guarantees: see 'opened', plus: either a changed field is in // PrivateTempStore, or the user has just modified a field without // activating (switching to) another field. // - Expected behavior: 1) if any of the fields are not yet in // PrivateTempStore, save them to PrivateTempStore, 2) if then any of // the fields has the 'invalid' state, then change the entity state back // to 'opened', otherwise: save the entity by committing it from // PrivateTempStore into permanent storage. 'committing', // User has clicked the 'Close' button, or has clicked the 'Save' button // and that was successfully completed. // - Trigger: user or EntityModel. // - Guarantees: when having clicked 'Close' hardly any: fields may be in // a variety of states; when having clicked 'Save': all fields are in // the 'candidate' state. // - Expected behavior: transition all fields to the 'candidate' state, // possibly requiring confirmation in the case of having clicked // 'Close'. 'deactivating', // Deactivation has been completed. // - Trigger: EntityModel. // - Guarantees: all fields are in the 'candidate' state. // - Expected behavior: change all fields to the 'inactive' state. 'closing', ], /** * Indicates whether the 'from' state comes before the 'to' state. * * @param {string} from * One of {@link Drupal.quickedit.EntityModel.states}. * @param {string} to * One of {@link Drupal.quickedit.EntityModel.states}. * * @return {bool} * Whether the 'from' state comes before the 'to' state. */ followsStateSequence(from, to) { return _.indexOf(this.states, from) < _.indexOf(this.states, to); }, }); /** * @constructor * * @augments Backbone.Collection */ Drupal.quickedit.EntityCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.EntityCollection# */{ /** * @type {Drupal.quickedit.EntityModel} */ model: Drupal.quickedit.EntityModel, }); }(_, jQuery, Backbone, Drupal));