3 * A Backbone Model for the state of an in-place editable entity in the DOM.
6 (function (_, $, Backbone, Drupal) {
10 Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.EntityModel# */{
15 defaults: /** @lends Drupal.quickedit.EntityModel# */{
18 * The DOM element that represents this entity.
20 * It may seem bizarre to have a DOM element in a Backbone Model, but we
21 * need to be able to map entities in the DOM to EntityModels in memory.
28 * An entity ID, of the form `<entity type>/<entity ID>`
38 * An entity instance ID.
40 * The first instance of a specific entity (i.e. with a given entity ID)
41 * is assigned 0, the second 1, and so on.
45 entityInstanceID: null,
48 * The unique ID of this entity instance on the page, of the form
49 * `<entity type>/<entity ID>[entity instance ID]`
59 * The label of the entity.
66 * A FieldCollection for all fields of the entity.
68 * @type {Drupal.quickedit.FieldCollection}
70 * @see Drupal.quickedit.FieldCollection
74 // The attributes below are stateful. The ones above will never change
75 // during the life of a EntityModel instance.
78 * Indicates whether this entity is currently being edited in-place.
85 * Whether one or more fields are already been stored in PrivateTempStore.
92 * Indicates whether a "Save" button is necessary or not.
94 * Whether one or more fields have already been stored in PrivateTempStore
95 * *or* the field that's currently being edited is in the 'changed' or a
103 * Whether the request to the server has been made to commit this entity.
105 * Used to prevent multiple such requests.
112 * The current processing state of an entity.
119 * IDs of fields whose new values have been stored in PrivateTempStore.
121 * We must store this on the EntityModel as well (even though it already
122 * is on the FieldModel) because when a field is rerendered, its
123 * FieldModel is destroyed and this allows us to transition it back to
126 * @type {Array.<string>}
128 fieldsInTempStore: [],
131 * A flag the tells the application that this EntityModel must be reloaded
132 * in order to restore the original values to its fields in the client.
142 * @augments Drupal.quickedit.BaseModel
144 initialize: function () {
145 this.set('fields', new Drupal.quickedit.FieldCollection());
147 // Respond to entity state changes.
148 this.listenTo(this, 'change:state', this.stateChange);
150 // The state of the entity is largely dependent on the state of its
152 this.listenTo(this.get('fields'), 'change:state', this.fieldStateChange);
154 // Call Drupal.quickedit.BaseModel's initialize() method.
155 Drupal.quickedit.BaseModel.prototype.initialize.call(this);
159 * Updates FieldModels' states when an EntityModel change occurs.
161 * @param {Drupal.quickedit.EntityModel} entityModel
163 * @param {string} state
164 * The state of the associated entity. One of
165 * {@link Drupal.quickedit.EntityModel.states}.
166 * @param {object} options
167 * Options for the entity model.
169 stateChange: function (entityModel, state, options) {
184 // Set the fields to candidate state.
185 entityModel.get('fields').each(function (fieldModel) {
186 fieldModel.set('state', 'candidate', options);
191 // The entity is now ready for editing!
192 this.set('isActive', true);
196 // The user indicated they want to save the entity.
197 var fields = this.get('fields');
198 // For fields that are in an active state, transition them to
201 .filter(function (fieldModel) {
202 return _.intersection([fieldModel.get('state')], ['active']).length;
204 .each(function (fieldModel) {
205 fieldModel.set('state', 'candidate');
207 // For fields that are in a changed state, field values must first be
208 // stored in PrivateTempStore.
210 .filter(function (fieldModel) {
211 return _.intersection([fieldModel.get('state')], Drupal.quickedit.app.changedFieldStates).length;
213 .each(function (fieldModel) {
214 fieldModel.set('state', 'saving');
219 var changedFields = this.get('fields')
220 .filter(function (fieldModel) {
221 return _.intersection([fieldModel.get('state')], ['changed', 'invalid']).length;
223 // If the entity contains unconfirmed or unsaved changes, return the
224 // entity to an opened state and ask the user if they would like to
225 // save the changes or discard the changes.
226 // 1. One of the fields is in a changed state. The changed field
227 // might just be a change in the client or it might have been saved
229 // 2. The saved flag is empty and the confirmed flag is empty. If
230 // the entity has been saved to the server, the fields changed in
231 // the client are irrelevant. If the changes are confirmed, then
232 // proceed to set the fields to candidate state.
233 if ((changedFields.length || this.get('fieldsInTempStore').length) && (!options.saved && !options.confirmed)) {
234 // Cancel deactivation until the user confirms save or discard.
235 this.set('state', 'opened', {confirming: true});
236 // An action in reaction to state change must be deferred.
237 _.defer(function () {
238 Drupal.quickedit.app.confirmEntityDeactivation(entityModel);
242 var invalidFields = this.get('fields')
243 .filter(function (fieldModel) {
244 return _.intersection([fieldModel.get('state')], ['invalid']).length;
246 // Indicate if this EntityModel needs to be reloaded in order to
247 // restore the original values of its fields.
248 entityModel.set('reload', (this.get('fieldsInTempStore').length || invalidFields.length));
249 // Set all fields to the 'candidate' state. A changed field may have
250 // to go through confirmation first.
251 entityModel.get('fields').each(function (fieldModel) {
252 // If the field is already in the candidate state, trigger a
253 // change event so that the entityModel can move to the next state
255 if (_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) {
256 fieldModel.trigger('change:state', fieldModel, fieldModel.get('state'), options);
259 fieldModel.set('state', 'candidate', options);
266 // Set all fields to the 'inactive' state.
267 options.reason = 'stop';
268 this.get('fields').each(function (fieldModel) {
279 * Updates a Field and Entity model's "inTempStore" when appropriate.
283 * @param {Drupal.quickedit.EntityModel} entityModel
284 * The model of the entity for which a field's state attribute has
286 * @param {Drupal.quickedit.FieldModel} fieldModel
287 * The model of the field whose state attribute has changed.
289 * @see Drupal.quickedit.EntityModel#fieldStateChange
291 _updateInTempStoreAttributes: function (entityModel, fieldModel) {
292 var current = fieldModel.get('state');
293 var previous = fieldModel.previous('state');
294 var fieldsInTempStore = entityModel.get('fieldsInTempStore');
295 // If the fieldModel changed to the 'saved' state: remember that this
296 // field was saved to PrivateTempStore.
297 if (current === 'saved') {
298 // Mark the entity as saved in PrivateTempStore, so that we can pass the
299 // proper "reset PrivateTempStore" boolean value when communicating with
301 entityModel.set('inTempStore', true);
302 // Mark the field as saved in PrivateTempStore, so that visual
303 // indicators signifying just that may be rendered.
304 fieldModel.set('inTempStore', true);
305 // Remember that this field is in PrivateTempStore, restore when
307 fieldsInTempStore.push(fieldModel.get('fieldID'));
308 fieldsInTempStore = _.uniq(fieldsInTempStore);
309 entityModel.set('fieldsInTempStore', fieldsInTempStore);
311 // If the fieldModel changed to the 'candidate' state from the
312 // 'inactive' state, then this is a field for this entity that got
313 // rerendered. Restore its previous 'inTempStore' attribute value.
314 else if (current === 'candidate' && previous === 'inactive') {
315 fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0);
320 * Reacts to state changes in this entity's fields.
322 * @param {Drupal.quickedit.FieldModel} fieldModel
323 * The model of the field whose state attribute changed.
324 * @param {string} state
325 * The state of the associated field. One of
326 * {@link Drupal.quickedit.FieldModel.states}.
328 fieldStateChange: function (fieldModel, state) {
329 var entityModel = this;
330 var fieldState = state;
331 // Switch on the entityModel state.
332 // The EntityModel responds to FieldModel state changes as a function of
333 // its state. For example, a field switching back to 'candidate' state
334 // when its entity is in the 'opened' state has no effect on the entity.
335 // But that same switch back to 'candidate' state of a field when the
336 // entity is in the 'committing' state might allow the entity to proceed
337 // with the commit flow.
338 switch (this.get('state')) {
341 // It should be impossible to reach these: fields can't change state
342 // while the entity is closed or still launching.
346 // We must change the entity to the 'opened' state, but it must first
347 // be confirmed that all of its fieldModels have transitioned to the
348 // 'candidate' state.
349 // We do this here, because this is called every time a fieldModel
350 // changes state, hence each time this is called, we get closer to the
351 // goal of having all fieldModels in the 'candidate' state.
352 // A state change in reaction to another state change must be
354 _.defer(function () {
355 entityModel.set('state', 'opened', {
356 'accept-field-states': Drupal.quickedit.app.readyFieldStates
362 // Set the isDirty attribute when appropriate so that it is known when
363 // to display the "Save" button in the entity toolbar.
364 // Note that once a field has been changed, there's no way to discard
365 // that change, hence it will have to be saved into PrivateTempStore,
366 // or the in-place editing of this field will have to be stopped
367 // completely. In other words: once any field enters the 'changed'
368 // field, then for the remainder of the in-place editing session, the
369 // entity is by definition dirty.
370 if (fieldState === 'changed') {
371 entityModel.set('isDirty', true);
374 this._updateInTempStoreAttributes(entityModel, fieldModel);
379 // If the field save returned a validation error, set the state of the
380 // entity back to 'opened'.
381 if (fieldState === 'invalid') {
382 // A state change in reaction to another state change must be
384 _.defer(function () {
385 entityModel.set('state', 'opened', {reason: 'invalid'});
389 this._updateInTempStoreAttributes(entityModel, fieldModel);
392 // Attempt to save the entity. If the entity's fields are not yet all
393 // in a ready state, the save will not be processed.
395 'accept-field-states': Drupal.quickedit.app.readyFieldStates
397 if (entityModel.set('isCommitting', true, options)) {
399 success: function () {
401 state: 'deactivating',
406 // Reset the "isCommitting" mutex.
407 entityModel.set('isCommitting', false);
408 // Change the state back to "opened", to allow the user to hit
409 // the "Save" button again.
410 entityModel.set('state', 'opened', {reason: 'networkerror'});
411 // Show a modal to inform the user of the network error.
412 var 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')});
413 Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message);
420 // When setting the entity to 'closing', require that all fieldModels
421 // are in either the 'candidate' or 'highlighted' state.
422 // A state change in reaction to another state change must be
424 _.defer(function () {
425 entityModel.set('state', 'closing', {
426 'accept-field-states': Drupal.quickedit.app.readyFieldStates
432 // When setting the entity to 'closed', require that all fieldModels
433 // are in the 'inactive' state.
434 // A state change in reaction to another state change must be
436 _.defer(function () {
437 entityModel.set('state', 'closed', {
438 'accept-field-states': ['inactive']
446 * Fires an AJAX request to the REST save URL for an entity.
448 * @param {object} options
449 * An object of options that contains:
450 * @param {function} [options.success]
451 * A function to invoke if the entity is successfully saved.
453 save: function (options) {
454 var entityModel = this;
456 // Create a Drupal.ajax instance to save the entity.
457 var entitySaverAjax = Drupal.ajax({
458 url: Drupal.url('quickedit/entity/' + entityModel.get('entityID')),
460 // Let the Drupal.quickedit.EntityModel Backbone model's error()
461 // method handle errors.
462 options.error.call(entityModel);
465 // Entity saved successfully.
466 entitySaverAjax.commands.quickeditEntitySaved = function (ajax, response, status) {
467 // All fields have been moved from PrivateTempStore to permanent
468 // storage, update the "inTempStore" attribute on FieldModels, on the
469 // EntityModel and clear EntityModel's "fieldInTempStore" attribute.
470 entityModel.get('fields').each(function (fieldModel) {
471 fieldModel.set('inTempStore', false);
473 entityModel.set('inTempStore', false);
474 entityModel.set('fieldsInTempStore', []);
476 // Invoke the optional success callback.
477 if (options.success) {
478 options.success.call(entityModel);
481 // Trigger the AJAX request, which will will return the
482 // quickeditEntitySaved AJAX command to which we then react.
483 entitySaverAjax.execute();
487 * Validate the entity model.
489 * @param {object} attrs
490 * The attributes changes in the save or set call.
491 * @param {object} options
492 * An object with the following option:
493 * @param {string} [options.reason]
494 * A string that conveys a particular reason to allow for an exceptional
496 * @param {Array} options.accept-field-states
497 * An array of strings that represent field states that the entities must
498 * be in to validate. For example, if `accept-field-states` is
499 * `['candidate', 'highlighted']`, then all the fields of the entity must
500 * be in either of these two states for the save or set call to
501 * validate and proceed.
504 * A string to say something about the state of the entity model.
506 validate: function (attrs, options) {
507 var acceptedFieldStates = options['accept-field-states'] || [];
509 // Validate state change.
510 var currentState = this.get('state');
511 var nextState = attrs.state;
512 if (currentState !== nextState) {
513 // Ensure it's a valid state.
514 if (_.indexOf(this.constructor.states, nextState) === -1) {
515 return '"' + nextState + '" is an invalid state';
518 // Ensure it's a state change that is allowed.
519 // Check if the acceptStateChange function accepts it.
520 if (!this._acceptStateChange(currentState, nextState, options)) {
521 return 'state change not accepted';
523 // If that function accepts it, then ensure all fields are also in an
525 else if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
526 return 'state change not accepted because fields are not in acceptable state';
530 // Validate setting isCommitting = true.
531 var currentIsCommitting = this.get('isCommitting');
532 var nextIsCommitting = attrs.isCommitting;
533 if (currentIsCommitting === false && nextIsCommitting === true) {
534 if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
535 return 'isCommitting change not accepted because fields are not in acceptable state';
538 else if (currentIsCommitting === true && nextIsCommitting === true) {
539 return 'isCommitting is a mutex, hence only changes are allowed';
544 * Checks if a state change can be accepted.
546 * @param {string} from
550 * @param {object} context
551 * Context for the check.
552 * @param {string} context.reason
553 * The reason for the state change.
554 * @param {bool} context.confirming
555 * Whether context is confirming or not.
558 * Whether the state change is accepted or not.
560 * @see Drupal.quickedit.AppView#acceptEditorStateChange
562 _acceptStateChange: function (from, to, context) {
565 // In general, enforce the states sequence. Disallow going back from a
566 // "later" state to an "earlier" state, except in explicitly allowed
568 if (!this.constructor.followsStateSequence(from, to)) {
571 // Allow: closing -> closed.
572 // Necessary to stop editing an entity.
573 if (from === 'closing' && to === 'closed') {
576 // Allow: committing -> opened.
577 // Necessary to be able to correct an invalid field, or to hit the
578 // "Save" button again after a server/network error.
579 else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) {
582 // Allow: deactivating -> opened.
583 // Necessary to be able to confirm changes with the user.
584 else if (from === 'deactivating' && to === 'opened' && context.confirming) {
587 // Allow: opened -> deactivating.
588 // Necessary to be able to stop editing.
589 else if (from === 'opened' && to === 'deactivating' && context.confirmed) {
598 * Checks if fields have acceptable states.
600 * @param {Array} acceptedFieldStates
601 * An array of acceptable field states to check for.
604 * Whether the fields have an acceptable state.
606 * @see Drupal.quickedit.EntityModel#validate
608 _fieldsHaveAcceptableStates: function (acceptedFieldStates) {
611 // If no acceptable field states are provided, assume all field states are
612 // acceptable. We want to let validation pass as a default and only
613 // check validity on calls to set that explicitly request it.
614 if (acceptedFieldStates.length > 0) {
615 var fieldStates = this.get('fields').pluck('state') || [];
616 // If not all fields are in one of the accepted field states, then we
617 // still can't allow this state change.
618 if (_.difference(fieldStates, acceptedFieldStates).length) {
627 * Destroys the entity model.
629 * @param {object} options
630 * Options for the entity model.
632 destroy: function (options) {
633 Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
635 this.stopListening();
637 // Destroy all fields of this entity.
638 this.get('fields').reset();
645 // We don't use REST updates to sync.
649 }, /** @lends Drupal.quickedit.EntityModel */{
652 * Sequence of all possible states an entity can be in during quickediting.
654 * @type {Array.<string>}
657 // Initial state, like field's 'inactive' OR the user has just finished
658 // in-place editing this entity.
659 // - Trigger: none (initial) or EntityModel (finished).
660 // - Expected behavior: (when not initial state): tear down
661 // EntityToolbarView, in-place editors and related views.
663 // User has activated in-place editing of this entity.
665 // - Expected behavior: the EntityToolbarView is gets set up, in-place
666 // editors (EditorViews) and related views for this entity's fields are
667 // set up. Upon completion of those, the state is changed to 'opening'.
669 // Launching has finished.
670 // - Trigger: application.
671 // - Guarantees: in-place editors ready for use, all entity and field
672 // views have been set up, all fields are in the 'inactive' state.
673 // - Expected behavior: all fields are changed to the 'candidate' state
674 // and once this is completed, the entity state will be changed to
677 // Opening has finished.
678 // - Trigger: EntityModel.
679 // - Guarantees: see 'opening', all fields are in the 'candidate' state.
680 // - Expected behavior: the user is able to actually use in-place editing.
682 // User has clicked the 'Save' button (and has thus changed at least one
685 // - Guarantees: see 'opened', plus: either a changed field is in
686 // PrivateTempStore, or the user has just modified a field without
687 // activating (switching to) another field.
688 // - Expected behavior: 1) if any of the fields are not yet in
689 // PrivateTempStore, save them to PrivateTempStore, 2) if then any of
690 // the fields has the 'invalid' state, then change the entity state back
691 // to 'opened', otherwise: save the entity by committing it from
692 // PrivateTempStore into permanent storage.
694 // User has clicked the 'Close' button, or has clicked the 'Save' button
695 // and that was successfully completed.
696 // - Trigger: user or EntityModel.
697 // - Guarantees: when having clicked 'Close' hardly any: fields may be in
698 // a variety of states; when having clicked 'Save': all fields are in
699 // the 'candidate' state.
700 // - Expected behavior: transition all fields to the 'candidate' state,
701 // possibly requiring confirmation in the case of having clicked
704 // Deactivation has been completed.
705 // - Trigger: EntityModel.
706 // - Guarantees: all fields are in the 'candidate' state.
707 // - Expected behavior: change all fields to the 'inactive' state.
712 * Indicates whether the 'from' state comes before the 'to' state.
714 * @param {string} from
715 * One of {@link Drupal.quickedit.EntityModel.states}.
717 * One of {@link Drupal.quickedit.EntityModel.states}.
720 * Whether the 'from' state comes before the 'to' state.
722 followsStateSequence: function (from, to) {
723 return _.indexOf(this.states, from) < _.indexOf(this.states, to);
731 * @augments Backbone.Collection
733 Drupal.quickedit.EntityCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.EntityCollection# */{
736 * @type {Drupal.quickedit.EntityModel}
738 model: Drupal.quickedit.EntityModel
741 }(_, jQuery, Backbone, Drupal));