codevise/pageflow

View on GitHub
entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js

Summary

Maintainability
C
1 day
Test Coverage
import {ContentElement} from '../ContentElement';
import Backbone from 'backbone';

// Allows recording changes to a section's content elements,
// persisting the changes to the server in a single request and
// applying them to the section once the requests succeeds.
export function Batch(entry, section) {
  // Shallow copy of the section's list of content elements to store
  // ordering changes and newly inserted content elements.
  const contentElements = section.contentElements.toArray();

  // Since contentElements is only a shallow copy, we cannot write
  // configuration changes to the actual content elements.
  const changedConfigurations = {};

  // Content elements that have been removed from contentElements
  // and shall be deleted on the server.
  const markedForDeletion = [];

  // Track whether changes have been recorded which need to be
  // persisted to the server.
  let isDirty = false;

  return {
    getAdjacent,
    getLength,
    split,
    maybeMerge,
    insertBefore,
    insertAfter,
    markForUpdate,
    markForDeletion,
    remove,
    save,
    saveIfDirty
  };

  function getAdjacent(contentElement) {
    const index = contentElements.indexOf(contentElement);

    return [
      contentElements[index - 1],
      contentElements[index + 1]
    ];
  }

  function getLength(contentElement) {
    return contentElement.getType().getLength ?
           contentElement.getType().getLength(getCurrentConfiguration(contentElement)) :
           0;
  }

  // Higher level transformations based on the more low level
  // transformations below:

  function split(contentElement, splitPoint, {insertAt} = {}) {
    const [c1, c2] = contentElement.getType().split(getCurrentConfiguration(contentElement), splitPoint);
    let splitOffContentElement;

    if (insertAt === 'before') {
      splitOffContentElement = new ContentElement({
        typeName: contentElement.get('typeName'),
        configuration: c1
      });

      insertBefore(contentElement, splitOffContentElement);
      markForUpdate(contentElement, c2);
    }
    else {
      splitOffContentElement = new ContentElement({
        typeName: contentElement.get('typeName'),
        configuration: c2
      });

      markForUpdate(contentElement, c1);
      insertAfter(contentElement, splitOffContentElement);
    }

    return splitOffContentElement;
  }

  function maybeMerge(before, after) {
    if (!before ||
        !after ||
        before.get('typeName') !== after.get('typeName') ||
        !before.getType().merge) {
      return;
    }

    const mergedConfiguration = before.getType().merge(getCurrentConfiguration(before),
                                                       getCurrentConfiguration(after));

    // Update the aleady persisted content element, if one has not yet
    // been persisted. For example, let X be a content element in
    // between two text blocks T1 and T2:
    //
    //   T1
    //     paragraph A
    //     paragraph B
    //   X
    //   T2
    //     paragraph C
    //
    // When X shall be moved between the two paragraphs of T1, the
    // second paragraph of T1 will first be split off into a new
    // content element T3:
    //
    //   T1
    //     paragraph A
    //   T3
    //     paragraph B
    //   X
    //   T2
    //     paragraph C
    //
    // Then X will be moved:
    //
    //   T1
    //     paragraph A
    //   X
    //   T3
    //     paragraph B
    //   T2
    //     paragraph C
    //
    // T3 and T2 become adjacent and need to be merged. We now want to
    // update T2 instead of creating T3 and deleting T2. Final state:
    //
    //   T1
    //     paragraph A
    //   X
    //   T2
    //     paragraph B
    //     paragraph C
    //
    if (before.isNew() && !after.isNew()) {
      remove(before);
      markForUpdate(after, mergedConfiguration);

      return after;
    }
    else {
      markForUpdate(before, mergedConfiguration);
      remove(after);

      if (!after.isNew()) {
        markForDeletion(after);
      }

      return before;
    }
  }

  function insertBefore(sibling, contentElement) {
    isDirty = true;
    contentElements.splice(contentElements.indexOf(sibling), 0, contentElement);
  }

  function insertAfter(sibling, contentElement) {
    isDirty = true;
    contentElements.splice(contentElements.indexOf(sibling) + 1, 0, contentElement);
  }

  function markForUpdate(contentElement, configuration) {
    isDirty = true;

    if (contentElement.isNew()) {
      contentElement.configuration.set(configuration);
    }
    else {
      changedConfigurations[contentElement.id] = configuration;
    }
  }

  function markForDeletion(contentElement) {
    isDirty = true;
    markedForDeletion.push(contentElement);
  }

  function remove(contentElement) {
    // We do not mark the batch as dirty here to allow removing an
    // element and adding it to another section. We are fine with
    // the resulting gap in the position attributes of the section's
    // content elements.
    contentElements.splice(contentElements.indexOf(contentElement), 1);
  }

  function getCurrentConfiguration(contentElement) {
    return changedConfigurations[contentElement.id] || contentElement.configuration.attributes;
  }

  // Functionality to assemble and perform the batch request to
  // persist the recorded changes:

  function saveIfDirty(options) {
    if (isDirty) {
      save(options);
    }
  }

  function save({success} = {}) {
    isDirty = false;

    Backbone.sync('update', section, {
      url: `${section.url()}/content_elements/batch`,
      attrs: {
        content_elements: createBatchItems()
      },

      success(response) {
        applyConfigurationChanges();
        applyPositions();
        applyAdditions(response);
        applyDeletions();

        section.contentElements.sort();

        if (success) {
          success();
        }
      }
    });
  }

  function createBatchItems() {
    return [
      ...contentElements.map(contentElement => {
        if (contentElement.isNew()) {
          return {
            typeName: contentElement.get('typeName'),
            configuration: contentElement.configuration.attributes
          };
        }
        else if (changedConfigurations[contentElement.id]) {
          return {
            id: contentElement.id,
            configuration: changedConfigurations[contentElement.id]
          };
        }
        else {
          return contentElement.pick('id');
        }
      }),
      ...markedForDeletion.map(contentElement => ({
        id: contentElement.id,
        _delete: true
      }))
    ];
  }

  // Functionality to apply the recorded changes to the underlying
  // section once the request succeeded:

  function applyAdditions(response) {
    contentElements.forEach((contentElement, index) => {
      if (contentElement.isNew()) {
        section.contentElements.add(contentElement);

        contentElement.set({
          id: response[index].id,
          permaId: response[index].permaId
        });
      }
      else if (contentElement.section !== section) {
        contentElement.section.contentElements.remove(contentElement);
        section.contentElements.add(contentElement);
      }
    });
  }

  function applyDeletions() {
    markedForDeletion.forEach(contentElement =>
      entry.contentElements.remove(contentElement)
    );
  }

  function applyPositions() {
    contentElements.forEach((contentElement, index) =>
      contentElement.set('position', index, {autoSave: false})
    );
  }

  function applyConfigurationChanges() {
    contentElements.forEach(contentElement => {
      if (changedConfigurations[contentElement.id]) {
        contentElement.configuration.set(changedConfigurations[contentElement.id], {autoSave: false});
      }
    });
  }
}