annotate core/modules/quickedit/js/models/EntityModel.es6.js @ 19:fa3358dc1485 tip

Add ndrum files
author Chris Cannam
date Wed, 28 Aug 2019 13:14:47 +0100
parents 129ea1e6d783
children
rev   line source
Chris@0 1 /**
Chris@0 2 * @file
Chris@0 3 * A Backbone Model for the state of an in-place editable entity in the DOM.
Chris@0 4 */
Chris@0 5
Chris@17 6 (function(_, $, Backbone, Drupal) {
Chris@17 7 Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(
Chris@17 8 /** @lends Drupal.quickedit.EntityModel# */ {
Chris@17 9 /**
Chris@17 10 * @type {object}
Chris@17 11 */
Chris@17 12 defaults: /** @lends Drupal.quickedit.EntityModel# */ {
Chris@17 13 /**
Chris@17 14 * The DOM element that represents this entity.
Chris@17 15 *
Chris@17 16 * It may seem bizarre to have a DOM element in a Backbone Model, but we
Chris@17 17 * need to be able to map entities in the DOM to EntityModels in memory.
Chris@17 18 *
Chris@17 19 * @type {HTMLElement}
Chris@17 20 */
Chris@17 21 el: null,
Chris@0 22
Chris@17 23 /**
Chris@17 24 * An entity ID, of the form `<entity type>/<entity ID>`
Chris@17 25 *
Chris@17 26 * @example
Chris@17 27 * "node/1"
Chris@17 28 *
Chris@17 29 * @type {string}
Chris@17 30 */
Chris@17 31 entityID: null,
Chris@17 32
Chris@17 33 /**
Chris@17 34 * An entity instance ID.
Chris@17 35 *
Chris@17 36 * The first instance of a specific entity (i.e. with a given entity ID)
Chris@17 37 * is assigned 0, the second 1, and so on.
Chris@17 38 *
Chris@17 39 * @type {number}
Chris@17 40 */
Chris@17 41 entityInstanceID: null,
Chris@17 42
Chris@17 43 /**
Chris@17 44 * The unique ID of this entity instance on the page, of the form
Chris@17 45 * `<entity type>/<entity ID>[entity instance ID]`
Chris@17 46 *
Chris@17 47 * @example
Chris@17 48 * "node/1[0]"
Chris@17 49 *
Chris@17 50 * @type {string}
Chris@17 51 */
Chris@17 52 id: null,
Chris@17 53
Chris@17 54 /**
Chris@17 55 * The label of the entity.
Chris@17 56 *
Chris@17 57 * @type {string}
Chris@17 58 */
Chris@17 59 label: null,
Chris@17 60
Chris@17 61 /**
Chris@17 62 * A FieldCollection for all fields of the entity.
Chris@17 63 *
Chris@17 64 * @type {Drupal.quickedit.FieldCollection}
Chris@17 65 *
Chris@17 66 * @see Drupal.quickedit.FieldCollection
Chris@17 67 */
Chris@17 68 fields: null,
Chris@17 69
Chris@17 70 // The attributes below are stateful. The ones above will never change
Chris@17 71 // during the life of a EntityModel instance.
Chris@17 72
Chris@17 73 /**
Chris@17 74 * Indicates whether this entity is currently being edited in-place.
Chris@17 75 *
Chris@17 76 * @type {bool}
Chris@17 77 */
Chris@17 78 isActive: false,
Chris@17 79
Chris@17 80 /**
Chris@17 81 * Whether one or more fields are already been stored in PrivateTempStore.
Chris@17 82 *
Chris@17 83 * @type {bool}
Chris@17 84 */
Chris@17 85 inTempStore: false,
Chris@17 86
Chris@17 87 /**
Chris@17 88 * Indicates whether a "Save" button is necessary or not.
Chris@17 89 *
Chris@17 90 * Whether one or more fields have already been stored in PrivateTempStore
Chris@17 91 * *or* the field that's currently being edited is in the 'changed' or a
Chris@17 92 * later state.
Chris@17 93 *
Chris@17 94 * @type {bool}
Chris@17 95 */
Chris@17 96 isDirty: false,
Chris@17 97
Chris@17 98 /**
Chris@17 99 * Whether the request to the server has been made to commit this entity.
Chris@17 100 *
Chris@17 101 * Used to prevent multiple such requests.
Chris@17 102 *
Chris@17 103 * @type {bool}
Chris@17 104 */
Chris@17 105 isCommitting: false,
Chris@17 106
Chris@17 107 /**
Chris@17 108 * The current processing state of an entity.
Chris@17 109 *
Chris@17 110 * @type {string}
Chris@17 111 */
Chris@17 112 state: 'closed',
Chris@17 113
Chris@17 114 /**
Chris@17 115 * IDs of fields whose new values have been stored in PrivateTempStore.
Chris@17 116 *
Chris@17 117 * We must store this on the EntityModel as well (even though it already
Chris@17 118 * is on the FieldModel) because when a field is rerendered, its
Chris@17 119 * FieldModel is destroyed and this allows us to transition it back to
Chris@17 120 * the proper state.
Chris@17 121 *
Chris@17 122 * @type {Array.<string>}
Chris@17 123 */
Chris@17 124 fieldsInTempStore: [],
Chris@17 125
Chris@17 126 /**
Chris@17 127 * A flag the tells the application that this EntityModel must be reloaded
Chris@17 128 * in order to restore the original values to its fields in the client.
Chris@17 129 *
Chris@17 130 * @type {bool}
Chris@17 131 */
Chris@17 132 reload: false,
Chris@17 133 },
Chris@0 134
Chris@0 135 /**
Chris@17 136 * @constructs
Chris@0 137 *
Chris@17 138 * @augments Drupal.quickedit.BaseModel
Chris@0 139 */
Chris@17 140 initialize() {
Chris@17 141 this.set('fields', new Drupal.quickedit.FieldCollection());
Chris@17 142
Chris@17 143 // Respond to entity state changes.
Chris@17 144 this.listenTo(this, 'change:state', this.stateChange);
Chris@17 145
Chris@17 146 // The state of the entity is largely dependent on the state of its
Chris@17 147 // fields.
Chris@17 148 this.listenTo(
Chris@17 149 this.get('fields'),
Chris@17 150 'change:state',
Chris@17 151 this.fieldStateChange,
Chris@17 152 );
Chris@17 153
Chris@17 154 // Call Drupal.quickedit.BaseModel's initialize() method.
Chris@17 155 Drupal.quickedit.BaseModel.prototype.initialize.call(this);
Chris@17 156 },
Chris@0 157
Chris@0 158 /**
Chris@17 159 * Updates FieldModels' states when an EntityModel change occurs.
Chris@0 160 *
Chris@17 161 * @param {Drupal.quickedit.EntityModel} entityModel
Chris@17 162 * The entity model
Chris@17 163 * @param {string} state
Chris@17 164 * The state of the associated entity. One of
Chris@17 165 * {@link Drupal.quickedit.EntityModel.states}.
Chris@17 166 * @param {object} options
Chris@17 167 * Options for the entity model.
Chris@0 168 */
Chris@17 169 stateChange(entityModel, state, options) {
Chris@17 170 const to = state;
Chris@17 171 switch (to) {
Chris@17 172 case 'closed':
Chris@17 173 this.set({
Chris@17 174 isActive: false,
Chris@17 175 inTempStore: false,
Chris@17 176 isDirty: false,
Chris@17 177 });
Chris@17 178 break;
Chris@17 179
Chris@17 180 case 'launching':
Chris@17 181 break;
Chris@17 182
Chris@17 183 case 'opening':
Chris@17 184 // Set the fields to candidate state.
Chris@17 185 entityModel.get('fields').each(fieldModel => {
Chris@17 186 fieldModel.set('state', 'candidate', options);
Chris@17 187 });
Chris@17 188 break;
Chris@17 189
Chris@17 190 case 'opened':
Chris@17 191 // The entity is now ready for editing!
Chris@17 192 this.set('isActive', true);
Chris@17 193 break;
Chris@17 194
Chris@17 195 case 'committing': {
Chris@17 196 // The user indicated they want to save the entity.
Chris@17 197 const fields = this.get('fields');
Chris@17 198 // For fields that are in an active state, transition them to
Chris@17 199 // candidate.
Chris@17 200 fields
Chris@17 201 .chain()
Chris@17 202 .filter(
Chris@17 203 fieldModel =>
Chris@17 204 _.intersection([fieldModel.get('state')], ['active']).length,
Chris@17 205 )
Chris@17 206 .each(fieldModel => {
Chris@17 207 fieldModel.set('state', 'candidate');
Chris@17 208 });
Chris@17 209 // For fields that are in a changed state, field values must first be
Chris@17 210 // stored in PrivateTempStore.
Chris@17 211 fields
Chris@17 212 .chain()
Chris@17 213 .filter(
Chris@17 214 fieldModel =>
Chris@17 215 _.intersection(
Chris@17 216 [fieldModel.get('state')],
Chris@17 217 Drupal.quickedit.app.changedFieldStates,
Chris@17 218 ).length,
Chris@17 219 )
Chris@17 220 .each(fieldModel => {
Chris@17 221 fieldModel.set('state', 'saving');
Chris@17 222 });
Chris@17 223 break;
Chris@17 224 }
Chris@17 225
Chris@17 226 case 'deactivating': {
Chris@17 227 const changedFields = this.get('fields').filter(
Chris@17 228 fieldModel =>
Chris@17 229 _.intersection(
Chris@17 230 [fieldModel.get('state')],
Chris@17 231 ['changed', 'invalid'],
Chris@17 232 ).length,
Chris@17 233 );
Chris@17 234 // If the entity contains unconfirmed or unsaved changes, return the
Chris@17 235 // entity to an opened state and ask the user if they would like to
Chris@17 236 // save the changes or discard the changes.
Chris@17 237 // 1. One of the fields is in a changed state. The changed field
Chris@17 238 // might just be a change in the client or it might have been saved
Chris@17 239 // to tempstore.
Chris@17 240 // 2. The saved flag is empty and the confirmed flag is empty. If
Chris@17 241 // the entity has been saved to the server, the fields changed in
Chris@17 242 // the client are irrelevant. If the changes are confirmed, then
Chris@17 243 // proceed to set the fields to candidate state.
Chris@17 244 if (
Chris@17 245 (changedFields.length || this.get('fieldsInTempStore').length) &&
Chris@17 246 (!options.saved && !options.confirmed)
Chris@17 247 ) {
Chris@17 248 // Cancel deactivation until the user confirms save or discard.
Chris@17 249 this.set('state', 'opened', { confirming: true });
Chris@17 250 // An action in reaction to state change must be deferred.
Chris@17 251 _.defer(() => {
Chris@17 252 Drupal.quickedit.app.confirmEntityDeactivation(entityModel);
Chris@17 253 });
Chris@17 254 } else {
Chris@17 255 const invalidFields = this.get('fields').filter(
Chris@17 256 fieldModel =>
Chris@17 257 _.intersection([fieldModel.get('state')], ['invalid']).length,
Chris@17 258 );
Chris@17 259 // Indicate if this EntityModel needs to be reloaded in order to
Chris@17 260 // restore the original values of its fields.
Chris@17 261 entityModel.set(
Chris@17 262 'reload',
Chris@17 263 this.get('fieldsInTempStore').length || invalidFields.length,
Chris@17 264 );
Chris@17 265 // Set all fields to the 'candidate' state. A changed field may have
Chris@17 266 // to go through confirmation first.
Chris@17 267 entityModel.get('fields').each(fieldModel => {
Chris@17 268 // If the field is already in the candidate state, trigger a
Chris@17 269 // change event so that the entityModel can move to the next state
Chris@17 270 // in deactivation.
Chris@17 271 if (
Chris@17 272 _.intersection(
Chris@17 273 [fieldModel.get('state')],
Chris@17 274 ['candidate', 'highlighted'],
Chris@17 275 ).length
Chris@17 276 ) {
Chris@17 277 fieldModel.trigger(
Chris@17 278 'change:state',
Chris@17 279 fieldModel,
Chris@17 280 fieldModel.get('state'),
Chris@17 281 options,
Chris@17 282 );
Chris@17 283 } else {
Chris@17 284 fieldModel.set('state', 'candidate', options);
Chris@17 285 }
Chris@17 286 });
Chris@17 287 }
Chris@17 288 break;
Chris@17 289 }
Chris@17 290
Chris@17 291 case 'closing':
Chris@17 292 // Set all fields to the 'inactive' state.
Chris@17 293 options.reason = 'stop';
Chris@17 294 this.get('fields').each(fieldModel => {
Chris@17 295 fieldModel.set(
Chris@17 296 {
Chris@17 297 inTempStore: false,
Chris@17 298 state: 'inactive',
Chris@17 299 },
Chris@17 300 options,
Chris@17 301 );
Chris@17 302 });
Chris@17 303 break;
Chris@17 304 }
Chris@17 305 },
Chris@0 306
Chris@0 307 /**
Chris@17 308 * Updates a Field and Entity model's "inTempStore" when appropriate.
Chris@0 309 *
Chris@17 310 * Helper function.
Chris@0 311 *
Chris@17 312 * @param {Drupal.quickedit.EntityModel} entityModel
Chris@17 313 * The model of the entity for which a field's state attribute has
Chris@17 314 * changed.
Chris@17 315 * @param {Drupal.quickedit.FieldModel} fieldModel
Chris@17 316 * The model of the field whose state attribute has changed.
Chris@17 317 *
Chris@17 318 * @see Drupal.quickedit.EntityModel#fieldStateChange
Chris@0 319 */
Chris@17 320 _updateInTempStoreAttributes(entityModel, fieldModel) {
Chris@17 321 const current = fieldModel.get('state');
Chris@17 322 const previous = fieldModel.previous('state');
Chris@17 323 let fieldsInTempStore = entityModel.get('fieldsInTempStore');
Chris@17 324 // If the fieldModel changed to the 'saved' state: remember that this
Chris@17 325 // field was saved to PrivateTempStore.
Chris@17 326 if (current === 'saved') {
Chris@17 327 // Mark the entity as saved in PrivateTempStore, so that we can pass the
Chris@17 328 // proper "reset PrivateTempStore" boolean value when communicating with
Chris@17 329 // the server.
Chris@17 330 entityModel.set('inTempStore', true);
Chris@17 331 // Mark the field as saved in PrivateTempStore, so that visual
Chris@17 332 // indicators signifying just that may be rendered.
Chris@17 333 fieldModel.set('inTempStore', true);
Chris@17 334 // Remember that this field is in PrivateTempStore, restore when
Chris@17 335 // rerendered.
Chris@17 336 fieldsInTempStore.push(fieldModel.get('fieldID'));
Chris@17 337 fieldsInTempStore = _.uniq(fieldsInTempStore);
Chris@17 338 entityModel.set('fieldsInTempStore', fieldsInTempStore);
Chris@17 339 }
Chris@17 340 // If the fieldModel changed to the 'candidate' state from the
Chris@17 341 // 'inactive' state, then this is a field for this entity that got
Chris@17 342 // rerendered. Restore its previous 'inTempStore' attribute value.
Chris@17 343 else if (current === 'candidate' && previous === 'inactive') {
Chris@17 344 fieldModel.set(
Chris@17 345 'inTempStore',
Chris@17 346 _.intersection([fieldModel.get('fieldID')], fieldsInTempStore)
Chris@17 347 .length > 0,
Chris@17 348 );
Chris@17 349 }
Chris@17 350 },
Chris@0 351
Chris@0 352 /**
Chris@17 353 * Reacts to state changes in this entity's fields.
Chris@0 354 *
Chris@17 355 * @param {Drupal.quickedit.FieldModel} fieldModel
Chris@17 356 * The model of the field whose state attribute changed.
Chris@17 357 * @param {string} state
Chris@17 358 * The state of the associated field. One of
Chris@17 359 * {@link Drupal.quickedit.FieldModel.states}.
Chris@0 360 */
Chris@17 361 fieldStateChange(fieldModel, state) {
Chris@17 362 const entityModel = this;
Chris@17 363 const fieldState = state;
Chris@17 364 // Switch on the entityModel state.
Chris@17 365 // The EntityModel responds to FieldModel state changes as a function of
Chris@17 366 // its state. For example, a field switching back to 'candidate' state
Chris@17 367 // when its entity is in the 'opened' state has no effect on the entity.
Chris@17 368 // But that same switch back to 'candidate' state of a field when the
Chris@17 369 // entity is in the 'committing' state might allow the entity to proceed
Chris@17 370 // with the commit flow.
Chris@17 371 switch (this.get('state')) {
Chris@17 372 case 'closed':
Chris@17 373 case 'launching':
Chris@17 374 // It should be impossible to reach these: fields can't change state
Chris@17 375 // while the entity is closed or still launching.
Chris@17 376 break;
Chris@17 377
Chris@17 378 case 'opening':
Chris@17 379 // We must change the entity to the 'opened' state, but it must first
Chris@17 380 // be confirmed that all of its fieldModels have transitioned to the
Chris@17 381 // 'candidate' state.
Chris@17 382 // We do this here, because this is called every time a fieldModel
Chris@17 383 // changes state, hence each time this is called, we get closer to the
Chris@17 384 // goal of having all fieldModels in the 'candidate' state.
Chris@17 385 // A state change in reaction to another state change must be
Chris@17 386 // deferred.
Chris@17 387 _.defer(() => {
Chris@17 388 entityModel.set('state', 'opened', {
Chris@17 389 'accept-field-states': Drupal.quickedit.app.readyFieldStates,
Chris@17 390 });
Chris@17 391 });
Chris@17 392 break;
Chris@17 393
Chris@17 394 case 'opened':
Chris@17 395 // Set the isDirty attribute when appropriate so that it is known when
Chris@17 396 // to display the "Save" button in the entity toolbar.
Chris@17 397 // Note that once a field has been changed, there's no way to discard
Chris@17 398 // that change, hence it will have to be saved into PrivateTempStore,
Chris@17 399 // or the in-place editing of this field will have to be stopped
Chris@17 400 // completely. In other words: once any field enters the 'changed'
Chris@17 401 // field, then for the remainder of the in-place editing session, the
Chris@17 402 // entity is by definition dirty.
Chris@17 403 if (fieldState === 'changed') {
Chris@17 404 entityModel.set('isDirty', true);
Chris@17 405 } else {
Chris@17 406 this._updateInTempStoreAttributes(entityModel, fieldModel);
Chris@17 407 }
Chris@17 408 break;
Chris@17 409
Chris@17 410 case 'committing': {
Chris@17 411 // If the field save returned a validation error, set the state of the
Chris@17 412 // entity back to 'opened'.
Chris@17 413 if (fieldState === 'invalid') {
Chris@17 414 // A state change in reaction to another state change must be
Chris@17 415 // deferred.
Chris@17 416 _.defer(() => {
Chris@17 417 entityModel.set('state', 'opened', { reason: 'invalid' });
Chris@17 418 });
Chris@17 419 } else {
Chris@17 420 this._updateInTempStoreAttributes(entityModel, fieldModel);
Chris@17 421 }
Chris@17 422
Chris@17 423 // Attempt to save the entity. If the entity's fields are not yet all
Chris@17 424 // in a ready state, the save will not be processed.
Chris@17 425 const options = {
Chris@17 426 'accept-field-states': Drupal.quickedit.app.readyFieldStates,
Chris@17 427 };
Chris@17 428 if (entityModel.set('isCommitting', true, options)) {
Chris@17 429 entityModel.save({
Chris@17 430 success() {
Chris@17 431 entityModel.set(
Chris@17 432 {
Chris@17 433 state: 'deactivating',
Chris@17 434 isCommitting: false,
Chris@17 435 },
Chris@17 436 { saved: true },
Chris@17 437 );
Chris@17 438 },
Chris@17 439 error() {
Chris@17 440 // Reset the "isCommitting" mutex.
Chris@17 441 entityModel.set('isCommitting', false);
Chris@17 442 // Change the state back to "opened", to allow the user to hit
Chris@17 443 // the "Save" button again.
Chris@17 444 entityModel.set('state', 'opened', {
Chris@17 445 reason: 'networkerror',
Chris@17 446 });
Chris@17 447 // Show a modal to inform the user of the network error.
Chris@17 448 const message = Drupal.t(
Chris@17 449 '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.',
Chris@17 450 { '@entity-title': entityModel.get('label') },
Chris@17 451 );
Chris@17 452 Drupal.quickedit.util.networkErrorModal(
Chris@17 453 Drupal.t('Network problem!'),
Chris@17 454 message,
Chris@17 455 );
Chris@17 456 },
Chris@17 457 });
Chris@17 458 }
Chris@17 459 break;
Chris@17 460 }
Chris@17 461
Chris@17 462 case 'deactivating':
Chris@17 463 // When setting the entity to 'closing', require that all fieldModels
Chris@17 464 // are in either the 'candidate' or 'highlighted' state.
Chris@17 465 // A state change in reaction to another state change must be
Chris@17 466 // deferred.
Chris@17 467 _.defer(() => {
Chris@17 468 entityModel.set('state', 'closing', {
Chris@17 469 'accept-field-states': Drupal.quickedit.app.readyFieldStates,
Chris@17 470 });
Chris@17 471 });
Chris@17 472 break;
Chris@17 473
Chris@17 474 case 'closing':
Chris@17 475 // When setting the entity to 'closed', require that all fieldModels
Chris@17 476 // are in the 'inactive' state.
Chris@17 477 // A state change in reaction to another state change must be
Chris@17 478 // deferred.
Chris@17 479 _.defer(() => {
Chris@17 480 entityModel.set('state', 'closed', {
Chris@17 481 'accept-field-states': ['inactive'],
Chris@17 482 });
Chris@17 483 });
Chris@17 484 break;
Chris@17 485 }
Chris@17 486 },
Chris@0 487
Chris@0 488 /**
Chris@17 489 * Fires an AJAX request to the REST save URL for an entity.
Chris@0 490 *
Chris@17 491 * @param {object} options
Chris@17 492 * An object of options that contains:
Chris@17 493 * @param {function} [options.success]
Chris@17 494 * A function to invoke if the entity is successfully saved.
Chris@0 495 */
Chris@17 496 save(options) {
Chris@17 497 const entityModel = this;
Chris@17 498
Chris@17 499 // Create a Drupal.ajax instance to save the entity.
Chris@17 500 const entitySaverAjax = Drupal.ajax({
Chris@17 501 url: Drupal.url(`quickedit/entity/${entityModel.get('entityID')}`),
Chris@17 502 error() {
Chris@17 503 // Let the Drupal.quickedit.EntityModel Backbone model's error()
Chris@17 504 // method handle errors.
Chris@17 505 options.error.call(entityModel);
Chris@17 506 },
Chris@17 507 });
Chris@17 508 // Entity saved successfully.
Chris@17 509 entitySaverAjax.commands.quickeditEntitySaved = function(
Chris@17 510 ajax,
Chris@17 511 response,
Chris@17 512 status,
Chris@17 513 ) {
Chris@17 514 // All fields have been moved from PrivateTempStore to permanent
Chris@17 515 // storage, update the "inTempStore" attribute on FieldModels, on the
Chris@17 516 // EntityModel and clear EntityModel's "fieldInTempStore" attribute.
Chris@17 517 entityModel.get('fields').each(fieldModel => {
Chris@17 518 fieldModel.set('inTempStore', false);
Chris@17 519 });
Chris@17 520 entityModel.set('inTempStore', false);
Chris@17 521 entityModel.set('fieldsInTempStore', []);
Chris@17 522
Chris@17 523 // Invoke the optional success callback.
Chris@17 524 if (options.success) {
Chris@17 525 options.success.call(entityModel);
Chris@17 526 }
Chris@17 527 };
Chris@17 528 // Trigger the AJAX request, which will will return the
Chris@17 529 // quickeditEntitySaved AJAX command to which we then react.
Chris@17 530 entitySaverAjax.execute();
Chris@17 531 },
Chris@0 532
Chris@0 533 /**
Chris@17 534 * Validate the entity model.
Chris@0 535 *
Chris@17 536 * @param {object} attrs
Chris@17 537 * The attributes changes in the save or set call.
Chris@17 538 * @param {object} options
Chris@17 539 * An object with the following option:
Chris@17 540 * @param {string} [options.reason]
Chris@17 541 * A string that conveys a particular reason to allow for an exceptional
Chris@17 542 * state change.
Chris@17 543 * @param {Array} options.accept-field-states
Chris@17 544 * An array of strings that represent field states that the entities must
Chris@17 545 * be in to validate. For example, if `accept-field-states` is
Chris@17 546 * `['candidate', 'highlighted']`, then all the fields of the entity must
Chris@17 547 * be in either of these two states for the save or set call to
Chris@17 548 * validate and proceed.
Chris@0 549 *
Chris@17 550 * @return {string}
Chris@17 551 * A string to say something about the state of the entity model.
Chris@0 552 */
Chris@17 553 validate(attrs, options) {
Chris@17 554 const acceptedFieldStates = options['accept-field-states'] || [];
Chris@0 555
Chris@17 556 // Validate state change.
Chris@17 557 const currentState = this.get('state');
Chris@17 558 const nextState = attrs.state;
Chris@17 559 if (currentState !== nextState) {
Chris@17 560 // Ensure it's a valid state.
Chris@17 561 if (_.indexOf(this.constructor.states, nextState) === -1) {
Chris@17 562 return `"${nextState}" is an invalid state`;
Chris@17 563 }
Chris@17 564
Chris@17 565 // Ensure it's a state change that is allowed.
Chris@17 566 // Check if the acceptStateChange function accepts it.
Chris@17 567 if (!this._acceptStateChange(currentState, nextState, options)) {
Chris@17 568 return 'state change not accepted';
Chris@17 569 }
Chris@17 570 // If that function accepts it, then ensure all fields are also in an
Chris@17 571 // acceptable state.
Chris@17 572 if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
Chris@17 573 return 'state change not accepted because fields are not in acceptable state';
Chris@17 574 }
Chris@17 575 }
Chris@17 576
Chris@17 577 // Validate setting isCommitting = true.
Chris@17 578 const currentIsCommitting = this.get('isCommitting');
Chris@17 579 const nextIsCommitting = attrs.isCommitting;
Chris@17 580 if (currentIsCommitting === false && nextIsCommitting === true) {
Chris@17 581 if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
Chris@17 582 return 'isCommitting change not accepted because fields are not in acceptable state';
Chris@17 583 }
Chris@17 584 } else if (currentIsCommitting === true && nextIsCommitting === true) {
Chris@17 585 return 'isCommitting is a mutex, hence only changes are allowed';
Chris@17 586 }
Chris@17 587 },
Chris@0 588
Chris@0 589 /**
Chris@17 590 * Checks if a state change can be accepted.
Chris@0 591 *
Chris@17 592 * @param {string} from
Chris@17 593 * From state.
Chris@17 594 * @param {string} to
Chris@17 595 * To state.
Chris@17 596 * @param {object} context
Chris@17 597 * Context for the check.
Chris@17 598 * @param {string} context.reason
Chris@17 599 * The reason for the state change.
Chris@17 600 * @param {bool} context.confirming
Chris@17 601 * Whether context is confirming or not.
Chris@17 602 *
Chris@17 603 * @return {bool}
Chris@17 604 * Whether the state change is accepted or not.
Chris@17 605 *
Chris@17 606 * @see Drupal.quickedit.AppView#acceptEditorStateChange
Chris@0 607 */
Chris@17 608 _acceptStateChange(from, to, context) {
Chris@17 609 let accept = true;
Chris@17 610
Chris@17 611 // In general, enforce the states sequence. Disallow going back from a
Chris@17 612 // "later" state to an "earlier" state, except in explicitly allowed
Chris@17 613 // cases.
Chris@17 614 if (!this.constructor.followsStateSequence(from, to)) {
Chris@17 615 accept = false;
Chris@17 616
Chris@17 617 // Allow: closing -> closed.
Chris@17 618 // Necessary to stop editing an entity.
Chris@17 619 if (from === 'closing' && to === 'closed') {
Chris@17 620 accept = true;
Chris@17 621 }
Chris@17 622 // Allow: committing -> opened.
Chris@17 623 // Necessary to be able to correct an invalid field, or to hit the
Chris@17 624 // "Save" button again after a server/network error.
Chris@17 625 else if (
Chris@17 626 from === 'committing' &&
Chris@17 627 to === 'opened' &&
Chris@17 628 context.reason &&
Chris@17 629 (context.reason === 'invalid' || context.reason === 'networkerror')
Chris@17 630 ) {
Chris@17 631 accept = true;
Chris@17 632 }
Chris@17 633 // Allow: deactivating -> opened.
Chris@17 634 // Necessary to be able to confirm changes with the user.
Chris@17 635 else if (
Chris@17 636 from === 'deactivating' &&
Chris@17 637 to === 'opened' &&
Chris@17 638 context.confirming
Chris@17 639 ) {
Chris@17 640 accept = true;
Chris@17 641 }
Chris@17 642 // Allow: opened -> deactivating.
Chris@17 643 // Necessary to be able to stop editing.
Chris@17 644 else if (
Chris@17 645 from === 'opened' &&
Chris@17 646 to === 'deactivating' &&
Chris@17 647 context.confirmed
Chris@17 648 ) {
Chris@17 649 accept = true;
Chris@17 650 }
Chris@17 651 }
Chris@17 652
Chris@17 653 return accept;
Chris@17 654 },
Chris@0 655
Chris@0 656 /**
Chris@17 657 * Checks if fields have acceptable states.
Chris@0 658 *
Chris@17 659 * @param {Array} acceptedFieldStates
Chris@17 660 * An array of acceptable field states to check for.
Chris@17 661 *
Chris@17 662 * @return {bool}
Chris@17 663 * Whether the fields have an acceptable state.
Chris@17 664 *
Chris@17 665 * @see Drupal.quickedit.EntityModel#validate
Chris@0 666 */
Chris@17 667 _fieldsHaveAcceptableStates(acceptedFieldStates) {
Chris@17 668 let accept = true;
Chris@17 669
Chris@17 670 // If no acceptable field states are provided, assume all field states are
Chris@17 671 // acceptable. We want to let validation pass as a default and only
Chris@17 672 // check validity on calls to set that explicitly request it.
Chris@17 673 if (acceptedFieldStates.length > 0) {
Chris@17 674 const fieldStates = this.get('fields').pluck('state') || [];
Chris@17 675 // If not all fields are in one of the accepted field states, then we
Chris@17 676 // still can't allow this state change.
Chris@17 677 if (_.difference(fieldStates, acceptedFieldStates).length) {
Chris@17 678 accept = false;
Chris@17 679 }
Chris@17 680 }
Chris@17 681
Chris@17 682 return accept;
Chris@17 683 },
Chris@0 684
Chris@0 685 /**
Chris@17 686 * Destroys the entity model.
Chris@0 687 *
Chris@17 688 * @param {object} options
Chris@17 689 * Options for the entity model.
Chris@0 690 */
Chris@17 691 destroy(options) {
Chris@17 692 Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
Chris@17 693
Chris@17 694 this.stopListening();
Chris@17 695
Chris@17 696 // Destroy all fields of this entity.
Chris@17 697 this.get('fields').reset();
Chris@17 698 },
Chris@0 699
Chris@0 700 /**
Chris@17 701 * @inheritdoc
Chris@0 702 */
Chris@17 703 sync() {
Chris@17 704 // We don't use REST updates to sync.
Chris@17 705 },
Chris@17 706 },
Chris@17 707 /** @lends Drupal.quickedit.EntityModel */ {
Chris@0 708 /**
Chris@17 709 * Sequence of all possible states an entity can be in during quickediting.
Chris@0 710 *
Chris@0 711 * @type {Array.<string>}
Chris@0 712 */
Chris@17 713 states: [
Chris@17 714 // Initial state, like field's 'inactive' OR the user has just finished
Chris@17 715 // in-place editing this entity.
Chris@17 716 // - Trigger: none (initial) or EntityModel (finished).
Chris@17 717 // - Expected behavior: (when not initial state): tear down
Chris@17 718 // EntityToolbarView, in-place editors and related views.
Chris@17 719 'closed',
Chris@17 720 // User has activated in-place editing of this entity.
Chris@17 721 // - Trigger: user.
Chris@17 722 // - Expected behavior: the EntityToolbarView is gets set up, in-place
Chris@17 723 // editors (EditorViews) and related views for this entity's fields are
Chris@17 724 // set up. Upon completion of those, the state is changed to 'opening'.
Chris@17 725 'launching',
Chris@17 726 // Launching has finished.
Chris@17 727 // - Trigger: application.
Chris@17 728 // - Guarantees: in-place editors ready for use, all entity and field
Chris@17 729 // views have been set up, all fields are in the 'inactive' state.
Chris@17 730 // - Expected behavior: all fields are changed to the 'candidate' state
Chris@17 731 // and once this is completed, the entity state will be changed to
Chris@17 732 // 'opened'.
Chris@17 733 'opening',
Chris@17 734 // Opening has finished.
Chris@17 735 // - Trigger: EntityModel.
Chris@17 736 // - Guarantees: see 'opening', all fields are in the 'candidate' state.
Chris@17 737 // - Expected behavior: the user is able to actually use in-place editing.
Chris@17 738 'opened',
Chris@17 739 // User has clicked the 'Save' button (and has thus changed at least one
Chris@17 740 // field).
Chris@17 741 // - Trigger: user.
Chris@17 742 // - Guarantees: see 'opened', plus: either a changed field is in
Chris@17 743 // PrivateTempStore, or the user has just modified a field without
Chris@17 744 // activating (switching to) another field.
Chris@17 745 // - Expected behavior: 1) if any of the fields are not yet in
Chris@17 746 // PrivateTempStore, save them to PrivateTempStore, 2) if then any of
Chris@17 747 // the fields has the 'invalid' state, then change the entity state back
Chris@17 748 // to 'opened', otherwise: save the entity by committing it from
Chris@17 749 // PrivateTempStore into permanent storage.
Chris@17 750 'committing',
Chris@17 751 // User has clicked the 'Close' button, or has clicked the 'Save' button
Chris@17 752 // and that was successfully completed.
Chris@17 753 // - Trigger: user or EntityModel.
Chris@17 754 // - Guarantees: when having clicked 'Close' hardly any: fields may be in
Chris@17 755 // a variety of states; when having clicked 'Save': all fields are in
Chris@17 756 // the 'candidate' state.
Chris@17 757 // - Expected behavior: transition all fields to the 'candidate' state,
Chris@17 758 // possibly requiring confirmation in the case of having clicked
Chris@17 759 // 'Close'.
Chris@17 760 'deactivating',
Chris@17 761 // Deactivation has been completed.
Chris@17 762 // - Trigger: EntityModel.
Chris@17 763 // - Guarantees: all fields are in the 'candidate' state.
Chris@17 764 // - Expected behavior: change all fields to the 'inactive' state.
Chris@17 765 'closing',
Chris@17 766 ],
Chris@0 767
Chris@0 768 /**
Chris@17 769 * Indicates whether the 'from' state comes before the 'to' state.
Chris@0 770 *
Chris@17 771 * @param {string} from
Chris@17 772 * One of {@link Drupal.quickedit.EntityModel.states}.
Chris@17 773 * @param {string} to
Chris@17 774 * One of {@link Drupal.quickedit.EntityModel.states}.
Chris@17 775 *
Chris@17 776 * @return {bool}
Chris@17 777 * Whether the 'from' state comes before the 'to' state.
Chris@0 778 */
Chris@17 779 followsStateSequence(from, to) {
Chris@17 780 return _.indexOf(this.states, from) < _.indexOf(this.states, to);
Chris@17 781 },
Chris@0 782 },
Chris@17 783 );
Chris@0 784
Chris@0 785 /**
Chris@0 786 * @constructor
Chris@0 787 *
Chris@0 788 * @augments Backbone.Collection
Chris@0 789 */
Chris@17 790 Drupal.quickedit.EntityCollection = Backbone.Collection.extend(
Chris@17 791 /** @lends Drupal.quickedit.EntityCollection# */ {
Chris@17 792 /**
Chris@17 793 * @type {Drupal.quickedit.EntityModel}
Chris@17 794 */
Chris@17 795 model: Drupal.quickedit.EntityModel,
Chris@17 796 },
Chris@17 797 );
Chris@17 798 })(_, jQuery, Backbone, Drupal);