Chris@0: /** Chris@0: * @file Chris@0: * A Backbone Model for the state of an in-place editable field in the DOM. Chris@0: */ Chris@0: Chris@17: (function(_, Backbone, Drupal) { Chris@17: Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend( Chris@17: /** @lends Drupal.quickedit.FieldModel# */ { Chris@17: /** Chris@17: * @type {object} Chris@17: */ Chris@17: defaults: /** @lends Drupal.quickedit.FieldModel# */ { Chris@17: /** Chris@17: * The DOM element that represents this field. It may seem bizarre to have Chris@17: * a DOM element in a Backbone Model, but we need to be able to map fields Chris@17: * in the DOM to FieldModels in memory. Chris@17: */ Chris@17: el: null, Chris@0: Chris@17: /** Chris@17: * A field ID, of the form Chris@17: * `////` Chris@17: * Chris@17: * @example Chris@17: * "node/1/field_tags/und/full" Chris@17: */ Chris@17: fieldID: null, Chris@17: Chris@17: /** Chris@17: * The unique ID of this field within its entity instance on the page, of Chris@17: * the form `////[entity instance ID]`. Chris@17: * Chris@17: * @example Chris@17: * "node/1/field_tags/und/full[0]" Chris@17: */ Chris@17: id: null, Chris@17: Chris@17: /** Chris@17: * A {@link Drupal.quickedit.EntityModel}. Its "fields" attribute, which Chris@17: * is a FieldCollection, is automatically updated to include this Chris@17: * FieldModel. Chris@17: */ Chris@17: entity: null, Chris@17: Chris@17: /** Chris@17: * This field's metadata as returned by the Chris@17: * QuickEditController::metadata(). Chris@17: */ Chris@17: metadata: null, Chris@17: Chris@17: /** Chris@17: * Callback function for validating changes between states. Receives the Chris@17: * previous state, new state, context, and a callback. Chris@17: */ Chris@17: acceptStateChange: null, Chris@17: Chris@17: /** Chris@17: * A logical field ID, of the form Chris@17: * `///`, i.e. the fieldID without Chris@17: * the view mode, to be able to identify other instances of the same Chris@17: * field on the page but rendered in a different view mode. Chris@17: * Chris@17: * @example Chris@17: * "node/1/field_tags/und". Chris@17: */ Chris@17: logicalFieldID: null, Chris@17: Chris@17: // The attributes below are stateful. The ones above will never change Chris@17: // during the life of a FieldModel instance. Chris@17: Chris@17: /** Chris@17: * In-place editing state of this field. Defaults to the initial state. Chris@17: * Possible values: {@link Drupal.quickedit.FieldModel.states}. Chris@17: */ Chris@17: state: 'inactive', Chris@17: Chris@17: /** Chris@17: * The field is currently in the 'changed' state or one of the following Chris@17: * states in which the field is still changed. Chris@17: */ Chris@17: isChanged: false, Chris@17: Chris@17: /** Chris@17: * Is tracked by the EntityModel, is mirrored here solely for decorative Chris@17: * purposes: so that FieldDecorationView.renderChanged() can react to it. Chris@17: */ Chris@17: inTempStore: false, Chris@17: Chris@17: /** Chris@17: * The full HTML representation of this field (with the element that has Chris@17: * the data-quickedit-field-id as the outer element). Used to propagate Chris@17: * changes from this field to other instances of the same field storage. Chris@17: */ Chris@17: html: null, Chris@17: Chris@17: /** Chris@17: * An object containing the full HTML representations (values) of other Chris@17: * view modes (keys) of this field, for other instances of this field Chris@17: * displayed in a different view mode. Chris@17: */ Chris@17: htmlForOtherViewModes: null, Chris@17: }, Chris@0: Chris@0: /** Chris@17: * State of an in-place editable field in the DOM. Chris@17: * Chris@17: * @constructs Chris@17: * Chris@17: * @augments Drupal.quickedit.BaseModel Chris@17: * Chris@17: * @param {object} options Chris@17: * Options for the field model. Chris@0: */ Chris@17: initialize(options) { Chris@17: // Store the original full HTML representation of this field. Chris@17: this.set('html', options.el.outerHTML); Chris@17: Chris@17: // Enlist field automatically in the associated entity's field collection. Chris@17: this.get('entity') Chris@17: .get('fields') Chris@17: .add(this); Chris@17: Chris@17: // Automatically generate the logical field ID. Chris@17: this.set( Chris@17: 'logicalFieldID', Chris@17: this.get('fieldID') Chris@17: .split('/') Chris@17: .slice(0, 4) Chris@17: .join('/'), Chris@17: ); Chris@17: Chris@17: // Call Drupal.quickedit.BaseModel's initialize() method. Chris@17: Drupal.quickedit.BaseModel.prototype.initialize.call(this, options); Chris@17: }, Chris@0: Chris@0: /** Chris@17: * Destroys the field model. Chris@0: * Chris@17: * @param {object} options Chris@17: * Options for the field model. Chris@0: */ Chris@17: destroy(options) { Chris@17: if (this.get('state') !== 'inactive') { Chris@17: throw new Error( Chris@17: 'FieldModel cannot be destroyed if it is not inactive state.', Chris@17: ); Chris@17: } Chris@17: Drupal.quickedit.BaseModel.prototype.destroy.call(this, options); 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@0: Chris@0: /** Chris@17: * Validate function for the field model. Chris@17: * 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@17: * Chris@17: * @return {string} Chris@17: * A string to say something about the state of the field model. Chris@0: */ Chris@17: validate(attrs, options) { Chris@17: const current = this.get('state'); Chris@17: const next = attrs.state; Chris@17: if (current !== next) { Chris@17: // Ensure it's a valid state. Chris@17: if (_.indexOf(this.constructor.states, next) === -1) { Chris@17: return `"${next}" is an invalid state`; Chris@17: } Chris@17: // Check if the acceptStateChange callback accepts it. Chris@17: if (!this.get('acceptStateChange')(current, next, options, this)) { Chris@17: return 'state change not accepted'; Chris@17: } Chris@17: } Chris@17: }, Chris@0: Chris@0: /** Chris@17: * Extracts the entity ID from this field's ID. Chris@17: * Chris@17: * @return {string} Chris@17: * An entity ID: a string of the format `/`. Chris@0: */ Chris@17: getEntityID() { Chris@17: return this.get('fieldID') Chris@17: .split('/') Chris@17: .slice(0, 2) Chris@17: .join('/'); Chris@17: }, Chris@0: Chris@0: /** Chris@17: * Extracts the view mode ID from this field's ID. Chris@17: * Chris@17: * @return {string} Chris@17: * A view mode ID. Chris@0: */ Chris@17: getViewMode() { Chris@17: return this.get('fieldID') Chris@17: .split('/') Chris@17: .pop(); Chris@17: }, Chris@0: Chris@0: /** Chris@17: * Find other instances of this field with different view modes. Chris@0: * Chris@17: * @return {Array} Chris@17: * An array containing view mode IDs. Chris@0: */ Chris@17: findOtherViewModes() { Chris@17: const currentField = this; Chris@17: const otherViewModes = []; Chris@17: Drupal.quickedit.collections.fields Chris@17: // Find all instances of fields that display the same logical field Chris@17: // (same entity, same field, just a different instance and maybe a Chris@17: // different view mode). Chris@17: .where({ logicalFieldID: currentField.get('logicalFieldID') }) Chris@17: .forEach(field => { Chris@17: // Ignore the current field and other fields with the same view mode. Chris@17: if ( Chris@17: field !== currentField && Chris@17: field.get('fieldID') !== currentField.get('fieldID') Chris@17: ) { Chris@17: otherViewModes.push(field.getViewMode()); Chris@17: } Chris@17: }); Chris@17: return otherViewModes; Chris@17: }, Chris@17: }, Chris@17: /** @lends Drupal.quickedit.FieldModel */ { Chris@17: /** Chris@17: * Sequence of all possible states a field can be in during quickediting. Chris@17: * Chris@17: * @type {Array.} Chris@17: */ Chris@17: states: [ Chris@17: // The field associated with this FieldModel is linked to an EntityModel; Chris@17: // the user can choose to start in-place editing that entity (and Chris@17: // consequently this field). No in-place editor (EditorView) is associated Chris@17: // with this field, because this field is not being in-place edited. Chris@17: // This is both the initial (not yet in-place editing) and the end state Chris@17: // (finished in-place editing). Chris@17: 'inactive', Chris@17: // The user is in-place editing this entity, and this field is a Chris@17: // candidate Chris@17: // for in-place editing. In-place editor should not Chris@17: // - Trigger: user. Chris@17: // - Guarantees: entity is ready, in-place editor (EditorView) is Chris@17: // associated with the field. Chris@17: // - Expected behavior: visual indicators Chris@17: // around the field indicate it is available for in-place editing, no Chris@17: // in-place editor presented yet. Chris@17: 'candidate', Chris@17: // User is highlighting this field. Chris@17: // - Trigger: user. Chris@17: // - Guarantees: see 'candidate'. Chris@17: // - Expected behavior: visual indicators to convey highlighting, in-place Chris@17: // editing toolbar shows field's label. Chris@17: 'highlighted', Chris@17: // User has activated the in-place editing of this field; in-place editor Chris@17: // is activating. Chris@17: // - Trigger: user. Chris@17: // - Guarantees: see 'candidate'. Chris@17: // - Expected behavior: loading indicator, in-place editor is loading Chris@17: // remote data (e.g. retrieve form from back-end). Upon retrieval of Chris@17: // remote data, the in-place editor transitions the field's state to Chris@17: // 'active'. Chris@17: 'activating', Chris@17: // In-place editor has finished loading remote data; ready for use. Chris@17: // - Trigger: in-place editor. Chris@17: // - Guarantees: see 'candidate'. Chris@17: // - Expected behavior: in-place editor for the field is ready for use. Chris@17: 'active', Chris@17: // User has modified values in the in-place editor. Chris@17: // - Trigger: user. Chris@17: // - Guarantees: see 'candidate', plus in-place editor is ready for use. Chris@17: // - Expected behavior: visual indicator of change. Chris@17: 'changed', Chris@17: // User is saving changed field data in in-place editor to Chris@17: // PrivateTempStore. The save mechanism of the in-place editor is called. Chris@17: // - Trigger: user. Chris@17: // - Guarantees: see 'candidate' and 'active'. Chris@17: // - Expected behavior: saving indicator, in-place editor is saving field Chris@17: // data into PrivateTempStore. Upon successful saving (without Chris@17: // validation errors), the in-place editor transitions the field's state Chris@17: // to 'saved', but to 'invalid' upon failed saving (with validation Chris@17: // errors). Chris@17: 'saving', Chris@17: // In-place editor has successfully saved the changed field. Chris@17: // - Trigger: in-place editor. Chris@17: // - Guarantees: see 'candidate' and 'active'. Chris@17: // - Expected behavior: transition back to 'candidate' state because the Chris@17: // deed is done. Then: 1) transition to 'inactive' to allow the field Chris@17: // to be rerendered, 2) destroy the FieldModel (which also destroys Chris@17: // attached views like the EditorView), 3) replace the existing field Chris@17: // HTML with the existing HTML and 4) attach behaviors again so that the Chris@17: // field becomes available again for in-place editing. Chris@17: 'saved', Chris@17: // In-place editor has failed to saved the changed field: there were Chris@17: // validation errors. Chris@17: // - Trigger: in-place editor. Chris@17: // - Guarantees: see 'candidate' and 'active'. Chris@17: // - Expected behavior: remain in 'invalid' state, let the user make more Chris@17: // changes so that he can save it again, without validation errors. Chris@17: 'invalid', Chris@17: ], Chris@0: Chris@0: /** Chris@17: * Indicates whether the 'from' state comes before the 'to' state. Chris@17: * Chris@17: * @param {string} from Chris@17: * One of {@link Drupal.quickedit.FieldModel.states}. Chris@17: * @param {string} to Chris@17: * One of {@link Drupal.quickedit.FieldModel.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.FieldCollection = Backbone.Collection.extend( Chris@17: /** @lends Drupal.quickedit.FieldCollection */ { Chris@17: /** Chris@17: * @type {Drupal.quickedit.FieldModel} Chris@17: */ Chris@17: model: Drupal.quickedit.FieldModel, Chris@17: }, Chris@17: ); Chris@17: })(_, Backbone, Drupal);