Mercurial > hg > isophonics-drupal-site
diff core/modules/quickedit/js/models/EntityModel.es6.js @ 0:4c8ae668cc8c
Initial import (non-working)
author | Chris Cannam |
---|---|
date | Wed, 29 Nov 2017 16:09:58 +0000 |
parents | |
children | 1fec387a4317 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/core/modules/quickedit/js/models/EntityModel.es6.js Wed Nov 29 16:09:58 2017 +0000 @@ -0,0 +1,729 @@ +/** + * @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 `<entity type>/<entity ID>` + * + * @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 type>/<entity ID>[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.<string>} + */ + 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. + var 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': + var 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. + var 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 <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>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.<string>} + */ + 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));