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