Mercurial > hg > isophonics-drupal-site
diff core/modules/quickedit/js/views/AppView.es6.js @ 0:4c8ae668cc8c
Initial import (non-working)
author | Chris Cannam |
---|---|
date | Wed, 29 Nov 2017 16:09:58 +0000 |
parents | |
children | 1fec387a4317 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/core/modules/quickedit/js/views/AppView.es6.js Wed Nov 29 16:09:58 2017 +0000 @@ -0,0 +1,592 @@ +/** + * @file + * A Backbone View that controls the overall "in-place editing application". + * + * @see Drupal.quickedit.AppModel + */ + +(function ($, _, Backbone, Drupal) { + // Indicates whether the page should be reloaded after in-place editing has + // shut down. A page reload is necessary to re-instate the original HTML of + // the edited fields if in-place editing has been canceled and one or more of + // the entity's fields were saved to PrivateTempStore: one of them may have + // been changed to the empty value and hence may have been rerendered as the + // empty string, which makes it impossible for Quick Edit to know where to + // restore the original HTML. + let reload = false; + + Drupal.quickedit.AppView = Backbone.View.extend(/** @lends Drupal.quickedit.AppView# */{ + + /** + * @constructs + * + * @augments Backbone.View + * + * @param {object} options + * An object with the following keys: + * @param {Drupal.quickedit.AppModel} options.model + * The application state model. + * @param {Drupal.quickedit.EntityCollection} options.entitiesCollection + * All on-page entities. + * @param {Drupal.quickedit.FieldCollection} options.fieldsCollection + * All on-page fields + */ + initialize(options) { + // AppView's configuration for handling states. + // @see Drupal.quickedit.FieldModel.states + this.activeFieldStates = ['activating', 'active']; + this.singleFieldStates = ['highlighted', 'activating', 'active']; + this.changedFieldStates = ['changed', 'saving', 'saved', 'invalid']; + this.readyFieldStates = ['candidate', 'highlighted']; + + // Track app state. + this.listenTo(options.entitiesCollection, 'change:state', this.appStateChange); + this.listenTo(options.entitiesCollection, 'change:isActive', this.enforceSingleActiveEntity); + + // Track app state. + this.listenTo(options.fieldsCollection, 'change:state', this.editorStateChange); + // Respond to field model HTML representation change events. + this.listenTo(options.fieldsCollection, 'change:html', this.renderUpdatedField); + this.listenTo(options.fieldsCollection, 'change:html', this.propagateUpdatedField); + // Respond to addition. + this.listenTo(options.fieldsCollection, 'add', this.rerenderedFieldToCandidate); + // Respond to destruction. + this.listenTo(options.fieldsCollection, 'destroy', this.teardownEditor); + }, + + /** + * Handles setup/teardown and state changes when the active entity changes. + * + * @param {Drupal.quickedit.EntityModel} entityModel + * An instance of the EntityModel class. + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.EntityModel.states}. + */ + appStateChange(entityModel, state) { + const app = this; + let entityToolbarView; + switch (state) { + case 'launching': + reload = false; + // First, create an entity toolbar view. + entityToolbarView = new Drupal.quickedit.EntityToolbarView({ + model: entityModel, + appModel: this.model, + }); + entityModel.toolbarView = entityToolbarView; + // Second, set up in-place editors. + // They must be notified of state changes, hence this must happen + // while the associated fields are still in the 'inactive' state. + entityModel.get('fields').each((fieldModel) => { + app.setupEditor(fieldModel); + }); + // Third, transition the entity to the 'opening' state, which will + // transition all fields from 'inactive' to 'candidate'. + _.defer(() => { + entityModel.set('state', 'opening'); + }); + break; + + case 'closed': + entityToolbarView = entityModel.toolbarView; + // First, tear down the in-place editors. + entityModel.get('fields').each((fieldModel) => { + app.teardownEditor(fieldModel); + }); + // Second, tear down the entity toolbar view. + if (entityToolbarView) { + entityToolbarView.remove(); + delete entityModel.toolbarView; + } + // A page reload may be necessary to re-instate the original HTML of + // the edited fields. + if (reload) { + reload = false; + location.reload(); + } + break; + } + }, + + /** + * Accepts or reject editor (Editor) state changes. + * + * This is what ensures that the app is in control of what happens. + * + * @param {string} from + * The previous state. + * @param {string} to + * The new state. + * @param {null|object} context + * The context that is trying to trigger the state change. + * @param {Drupal.quickedit.FieldModel} fieldModel + * The fieldModel to which this change applies. + * + * @return {bool} + * Whether the editor change was accepted or rejected. + */ + acceptEditorStateChange(from, to, context, fieldModel) { + let accept = true; + + // If the app is in view mode, then reject all state changes except for + // those to 'inactive'. + if (context && (context.reason === 'stop' || context.reason === 'rerender')) { + if (from === 'candidate' && to === 'inactive') { + accept = true; + } + } + // Handling of edit mode state changes is more granular. + else { + // In general, enforce the states sequence. Disallow going back from a + // "later" state to an "earlier" state, except in explicitly allowed + // cases. + if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) { + accept = false; + // Allow: activating/active -> candidate. + // Necessary to stop editing a field. + if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') { + accept = true; + } + // Allow: changed/invalid -> candidate. + // Necessary to stop editing a field when it is changed or invalid. + else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { + accept = true; + } + // Allow: highlighted -> candidate. + // Necessary to stop highlighting a field. + else if (from === 'highlighted' && to === 'candidate') { + accept = true; + } + // Allow: saved -> candidate. + // Necessary when successfully saved a field. + else if (from === 'saved' && to === 'candidate') { + accept = true; + } + // Allow: invalid -> saving. + // Necessary to be able to save a corrected, invalid field. + else if (from === 'invalid' && to === 'saving') { + accept = true; + } + // Allow: invalid -> activating. + // Necessary to be able to correct a field that turned out to be + // invalid after the user already had moved on to the next field + // (which we explicitly allow to have a fluent UX). + else if (from === 'invalid' && to === 'activating') { + accept = true; + } + } + + // If it's not against the general principle, then here are more + // disallowed cases to check. + if (accept) { + let activeField; + let activeFieldState; + // Ensure only one field (editor) at a time is active … but allow a + // user to hop from one field to the next, even if we still have to + // start saving the field that is currently active: assume it will be + // valid, to allow for a fluent UX. (If it turns out to be invalid, + // this block of code also handles that.) + if ((this.readyFieldStates.indexOf(from) !== -1 || from === 'invalid') && this.activeFieldStates.indexOf(to) !== -1) { + activeField = this.model.get('activeField'); + if (activeField && activeField !== fieldModel) { + activeFieldState = activeField.get('state'); + // Allow the state change. If the state of the active field is: + // - 'activating' or 'active': change it to 'candidate' + // - 'changed' or 'invalid': change it to 'saving' + // - 'saving' or 'saved': don't do anything. + if (this.activeFieldStates.indexOf(activeFieldState) !== -1) { + activeField.set('state', 'candidate'); + } + else if (activeFieldState === 'changed' || activeFieldState === 'invalid') { + activeField.set('state', 'saving'); + } + + // If the field that's being activated is in fact already in the + // invalid state (which can only happen because above we allowed + // the user to move on to another field to allow for a fluent UX; + // we assumed it would be saved successfully), then we shouldn't + // allow the field to enter the 'activating' state, instead, we + // simply change the active editor. All guarantees and + // assumptions for this field still hold! + if (from === 'invalid') { + this.model.set('activeField', fieldModel); + accept = false; + } + // Do not reject: the field is either in the 'candidate' or + // 'highlighted' state and we allow it to enter the 'activating' + // state! + } + } + // Reject going from activating/active to candidate because of a + // mouseleave. + else if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + } + // When attempting to stop editing a changed/invalid property, ask for + // confirmation. + else if ((from === 'changed' || from === 'invalid') && to === 'candidate') { + if (context && context.reason === 'mouseleave') { + accept = false; + } + else { + // Check whether the transition has been confirmed? + if (context && context.confirmed) { + accept = true; + } + } + } + } + } + + return accept; + }, + + /** + * Sets up the in-place editor for the given field. + * + * Must happen before the fieldModel's state is changed to 'candidate'. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The field for which an in-place editor must be set up. + */ + setupEditor(fieldModel) { + // Get the corresponding entity toolbar. + const entityModel = fieldModel.get('entity'); + const entityToolbarView = entityModel.toolbarView; + // Get the field toolbar DOM root from the entity toolbar. + const fieldToolbarRoot = entityToolbarView.getToolbarRoot(); + // Create in-place editor. + const editorName = fieldModel.get('metadata').editor; + const editorModel = new Drupal.quickedit.EditorModel(); + const editorView = new Drupal.quickedit.editors[editorName]({ + el: $(fieldModel.get('el')), + model: editorModel, + fieldModel, + }); + + // Create in-place editor's toolbar for this field — stored inside the + // entity toolbar, the entity toolbar will position itself appropriately + // above (or below) the edited element. + const toolbarView = new Drupal.quickedit.FieldToolbarView({ + el: fieldToolbarRoot, + model: fieldModel, + $editedElement: $(editorView.getEditedElement()), + editorView, + entityModel, + }); + + // Create decoration for edited element: padding if necessary, sets + // classes on the element to style it according to the current state. + const decorationView = new Drupal.quickedit.FieldDecorationView({ + el: $(editorView.getEditedElement()), + model: fieldModel, + editorView, + }); + + // Track these three views in FieldModel so that we can tear them down + // correctly. + fieldModel.editorView = editorView; + fieldModel.toolbarView = toolbarView; + fieldModel.decorationView = decorationView; + }, + + /** + * Tears down the in-place editor for the given field. + * + * Must happen after the fieldModel's state is changed to 'inactive'. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The field for which an in-place editor must be torn down. + */ + teardownEditor(fieldModel) { + // Early-return if this field was not yet decorated. + if (typeof fieldModel.editorView === 'undefined') { + return; + } + + // Unbind event handlers; remove toolbar element; delete toolbar view. + fieldModel.toolbarView.remove(); + delete fieldModel.toolbarView; + + // Unbind event handlers; delete decoration view. Don't remove the element + // because that would remove the field itself. + fieldModel.decorationView.remove(); + delete fieldModel.decorationView; + + // Unbind event handlers; delete editor view. Don't remove the element + // because that would remove the field itself. + fieldModel.editorView.remove(); + delete fieldModel.editorView; + }, + + /** + * Asks the user to confirm whether he wants to stop editing via a modal. + * + * @param {Drupal.quickedit.EntityModel} entityModel + * An instance of the EntityModel class. + * + * @see Drupal.quickedit.AppView#acceptEditorStateChange + */ + confirmEntityDeactivation(entityModel) { + const that = this; + let discardDialog; + + function closeDiscardDialog(action) { + discardDialog.close(action); + // The active modal has been removed. + that.model.set('activeModal', null); + + // If the targetState is saving, the field must be saved, then the + // entity must be saved. + if (action === 'save') { + entityModel.set('state', 'committing', { confirmed: true }); + } + else { + entityModel.set('state', 'deactivating', { confirmed: true }); + // Editing has been canceled and the changes will not be saved. Mark + // the page for reload if the entityModel declares that it requires + // a reload. + if (entityModel.get('reload')) { + reload = true; + entityModel.set('reload', false); + } + } + } + + // Only instantiate if there isn't a modal instance visible yet. + if (!this.model.get('activeModal')) { + const $unsavedChanges = $(`<div>${Drupal.t('You have unsaved changes')}</div>`); + discardDialog = Drupal.dialog($unsavedChanges.get(0), { + title: Drupal.t('Discard changes?'), + dialogClass: 'quickedit-discard-modal', + resizable: false, + buttons: [ + { + text: Drupal.t('Save'), + click() { + closeDiscardDialog('save'); + }, + primary: true, + }, + { + text: Drupal.t('Discard changes'), + click() { + closeDiscardDialog('discard'); + }, + }, + ], + // Prevent this modal from being closed without the user making a + // choice as per http://stackoverflow.com/a/5438771. + closeOnEscape: false, + create() { + $(this).parent().find('.ui-dialog-titlebar-close').remove(); + }, + beforeClose: false, + close(event) { + // Automatically destroy the DOM element that was used for the + // dialog. + $(event.target).remove(); + }, + }); + this.model.set('activeModal', discardDialog); + + discardDialog.showModal(); + } + }, + + /** + * Reacts to field state changes; tracks global state. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The `fieldModel` holding the state. + * @param {string} state + * The state of the associated field. One of + * {@link Drupal.quickedit.FieldModel.states}. + */ + editorStateChange(fieldModel, state) { + const from = fieldModel.previous('state'); + const to = state; + + // Keep track of the highlighted field in the global state. + if (_.indexOf(this.singleFieldStates, to) !== -1 && this.model.get('highlightedField') !== fieldModel) { + this.model.set('highlightedField', fieldModel); + } + else if (this.model.get('highlightedField') === fieldModel && to === 'candidate') { + this.model.set('highlightedField', null); + } + + // Keep track of the active field in the global state. + if (_.indexOf(this.activeFieldStates, to) !== -1 && this.model.get('activeField') !== fieldModel) { + this.model.set('activeField', fieldModel); + } + else if (this.model.get('activeField') === fieldModel && to === 'candidate') { + // Discarded if it transitions from a changed state to 'candidate'. + if (from === 'changed' || from === 'invalid') { + fieldModel.editorView.revert(); + } + this.model.set('activeField', null); + } + }, + + /** + * Render an updated field (a field whose 'html' attribute changed). + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * The FieldModel whose 'html' attribute changed. + * @param {string} html + * The updated 'html' attribute. + * @param {object} options + * An object with the following keys: + * @param {bool} options.propagation + * Whether this change to the 'html' attribute occurred because of the + * propagation of changes to another instance of this field. + */ + renderUpdatedField(fieldModel, html, options) { + // Get data necessary to rerender property before it is unavailable. + const $fieldWrapper = $(fieldModel.get('el')); + const $context = $fieldWrapper.parent(); + + const renderField = function () { + // Destroy the field model; this will cause all attached views to be + // destroyed too, and removal from all collections in which it exists. + fieldModel.destroy(); + + // Replace the old content with the new content. + $fieldWrapper.replaceWith(html); + + // Attach behaviors again to the modified piece of HTML; this will + // create a new field model and call rerenderedFieldToCandidate() with + // it. + Drupal.attachBehaviors($context.get(0)); + }; + + // When propagating the changes of another instance of this field, this + // field is not being actively edited and hence no state changes are + // necessary. So: only update the state of this field when the rerendering + // of this field happens not because of propagation, but because it is + // being edited itself. + if (!options.propagation) { + // Deferred because renderUpdatedField is reacting to a field model + // change event, and we want to make sure that event fully propagates + // before making another change to the same model. + _.defer(() => { + // First set the state to 'candidate', to allow all attached views to + // clean up all their "active state"-related changes. + fieldModel.set('state', 'candidate'); + + // Similarly, the above .set() call's change event must fully + // propagate before calling it again. + _.defer(() => { + // Set the field's state to 'inactive', to enable the updating of + // its DOM value. + fieldModel.set('state', 'inactive', { reason: 'rerender' }); + + renderField(); + }); + }); + } + else { + renderField(); + } + }, + + /** + * Propagates changes to an updated field to all instances of that field. + * + * @param {Drupal.quickedit.FieldModel} updatedField + * The FieldModel whose 'html' attribute changed. + * @param {string} html + * The updated 'html' attribute. + * @param {object} options + * An object with the following keys: + * @param {bool} options.propagation + * Whether this change to the 'html' attribute occurred because of the + * propagation of changes to another instance of this field. + * + * @see Drupal.quickedit.AppView#renderUpdatedField + */ + propagateUpdatedField(updatedField, html, options) { + // Don't propagate field updates that themselves were caused by + // propagation. + if (options.propagation) { + return; + } + + const htmlForOtherViewModes = updatedField.get('htmlForOtherViewModes'); + Drupal.quickedit.collections.fields + // Find all instances of fields that display the same logical field + // (same entity, same field, just a different instance and maybe a + // different view mode). + .where({ logicalFieldID: updatedField.get('logicalFieldID') }) + .forEach((field) => { + // Ignore the field that was already updated. + if (field === updatedField) { + + } + // If this other instance of the field has the same view mode, we can + // update it easily. + else if (field.getViewMode() === updatedField.getViewMode()) { + field.set('html', updatedField.get('html')); + } + // If this other instance of the field has a different view mode, and + // that is one of the view modes for which a re-rendered version is + // available (and that should be the case unless this field was only + // added to the page after editing of the updated field began), then + // use that view mode's re-rendered version. + else if (field.getViewMode() in htmlForOtherViewModes) { + field.set('html', htmlForOtherViewModes[field.getViewMode()], { propagation: true }); + } + }); + }, + + /** + * If the new in-place editable field is for the entity that's currently + * being edited, then transition it to the 'candidate' state. + * + * This happens when a field was modified, saved and hence rerendered. + * + * @param {Drupal.quickedit.FieldModel} fieldModel + * A field that was just added to the collection of fields. + */ + rerenderedFieldToCandidate(fieldModel) { + const activeEntity = Drupal.quickedit.collections.entities.findWhere({ isActive: true }); + + // Early-return if there is no active entity. + if (!activeEntity) { + return; + } + + // If the field's entity is the active entity, make it a candidate. + if (fieldModel.get('entity') === activeEntity) { + this.setupEditor(fieldModel); + fieldModel.set('state', 'candidate'); + } + }, + + /** + * EntityModel Collection change handler. + * + * Handler is called `change:isActive` and enforces a single active entity. + * + * @param {Drupal.quickedit.EntityModel} changedEntityModel + * The entityModel instance whose active state has changed. + */ + enforceSingleActiveEntity(changedEntityModel) { + // When an entity is deactivated, we don't need to enforce anything. + if (changedEntityModel.get('isActive') === false) { + return; + } + + // This entity was activated; deactivate all other entities. + changedEntityModel.collection.chain() + .filter(entityModel => entityModel.get('isActive') === true && entityModel !== changedEntityModel) + .each((entityModel) => { + entityModel.set('state', 'deactivating'); + }); + }, + + }); +}(jQuery, _, Backbone, Drupal));