18F/cg-dashboard

View on GitHub
static_src/stores/base_store.js

Summary

Maintainability
A
35 mins
Test Coverage
/*
 * Includes basic functionality that all stores need.
 */

import { EventEmitter } from "events";
import Immutable from "immutable";

import AppDispatcher from "../dispatcher.js";
import LoadingStatus from "../util/loading_status.js";

// TODO clean up the listeners and reduce the MAX_LISTENERS to 15
const MAX_LISTENERS = 20;
const MAX_LISTENERS_THRESHOLD = 10;

function defaultChangedCallback(changed) {
  if (changed) this.emitChange();
}

export default class BaseStore extends EventEmitter {
  constructor() {
    super();
    this.loadingStatus = new LoadingStatus();
    this.loadingStatus.on("loading", () => this.emitChange());
    this.loadingStatus.on("loaded", () => this.emitChange());
    this.storeData = new Immutable.List();
    this.listenerCount = 0;
    this.setMaxListeners(MAX_LISTENERS);
  }

  subscribe(actionSubscribe) {
    this.dispatchToken = AppDispatcher.register(actionSubscribe());
  }

  unsubscribe() {
    AppDispatcher.unregister(this.dispatchToken);
  }

  get loading() {
    return !this.loadingStatus.isLoaded;
  }

  isEmpty() {
    if (this.storeData.count() === 0) return true;
    return false;
  }

  push(val) {
    const newData = Immutable.fromJS(val);
    this.storeData = this.storeData.push(newData);
    this.emitChange();

    return this.storeData;
  }

  get(guid) {
    if (guid && !this.isEmpty()) {
      const item = this.storeData.find(space => space.get("guid") === guid);

      if (item) return item.toJS();
    }
    return undefined;
  }

  getAll() {
    return this.storeData.toJS();
  }

  dataHasChanged(toCompare) {
    const c = Immutable.fromJS(toCompare);
    return !Immutable.is(this.storeData, c);
  }

  deleteProp(guid, prop, cb = defaultChangedCallback.bind(this)) {
    const index = this.storeData.findIndex(d => d.get("guid") === guid);

    if (index === -1) return cb(false);

    this.storeData = this.storeData.deleteIn([index, prop]);
    return cb(true);
  }

  delete(guid, cb = defaultChangedCallback.bind(this)) {
    const index = this.storeData.findIndex(d => d.get("guid") === guid);

    if (index === -1) return cb(false);

    this.storeData = this.storeData.delete(index);
    return cb(true);
  }

  clear(cb = defaultChangedCallback.bind(this)) {
    const old = this.storeData;
    this.storeData = this.storeData.clear();
    return cb(!this.storeData.equals(old));
  }

  formatSplitResponse(resources) {
    return resources.map(r => Object.assign(r.entity, r.metadata));
  }

  emitChange() {
    this.emit("CHANGE");
  }

  addChangeListener(cb) {
    if (++this.listenerCount > MAX_LISTENERS_THRESHOLD) {
      /* eslint-disable no-console */
      console.warn("listener count is above threshold", {
        store: this.constructor.name,
        count: this.listenerCount
      });
      console.trace();
      /* eslint-enable no-console */
    }

    this.on("CHANGE", cb);
  }

  removeChangeListener(cb) {
    this.removeListener("CHANGE", cb);
    this.listenerCount--;
  }

  load(promises) {
    this.loadingStatus.load(promises);
  }

  /* merge with no side effects
   *
   * If the dataToMerge exists in the store (as found by
   * the mergeKey), then merge in new data if there are
   * changes. If it does not exist in the store, add it.
   * When data changes, calls dataChangedCallback with
   * true as the argument. Otherwise, false is used
   *
   * @param {string} mergeKey - use to match objects
   * @param {object} dataToMerge - new object with data
   * @param {fn} cb - defaults to calling this.emitChange()
   *                  when the store's data has changed
   *
  */
  merge(mergeKey, dataToMerge, cb = defaultChangedCallback.bind(this)) {
    const toMerge = Immutable.fromJS(dataToMerge);
    const oldDataItem = this.storeData.find(
      d => d.get(mergeKey) === toMerge.get(mergeKey)
    );

    if (oldDataItem) {
      if (Immutable.is(oldDataItem, toMerge)) {
        return cb(false);
      }

      this.storeData = this.storeData.map(d => {
        if (Immutable.is(d, oldDataItem)) {
          return oldDataItem.merge(toMerge);
        }
        return d;
      });
    } else {
      this.storeData = this.storeData.push(toMerge);
    }

    return cb(true);
  }

  /* mergeAll
   *
   * If the dataToMerge exists in the store (as found by
   * the mergeKey), then merge in new data if there are
   * changes. The merge algorithm differs from the one
   * in .merge() as this will merge the new data into all
   * entities in the store that match on the merge key and
   * not just the first one.
   *
   * If it does not exist in the store, then do nothing.
   *
   * @param {string} mergeKey - use to match objects
   * @param {object} dataToMerge - new object with data
   * @param {fn} cb - defaults to calling this.emitChange()
   *                  when the store's data has changed
   *
  */
  mergeAll(mergeKey, dataToMerge, cb = defaultChangedCallback.bind(this)) {
    let dataHasChanged = false;
    const toMerge = Immutable.fromJS(dataToMerge);
    const matches = this.storeData.find(
      d => d.get(mergeKey) === toMerge.get(mergeKey)
    );

    if (!matches) return cb(false);

    this.storeData = this.storeData.map(d => {
      const shouldMerge = d.get(mergeKey) === toMerge.get(mergeKey);
      if (!shouldMerge) return d;

      const merged = d.merge(toMerge);
      if (Immutable.is(d, merged)) {
        return d;
      }

      dataHasChanged = true;
      return merged;
    });

    return cb(dataHasChanged);
  }

  mergeMany(
    mergeKey,
    arrayOfDataToMerge,
    cb = defaultChangedCallback.bind(this)
  ) {
    let dataHasChanged = false;
    arrayOfDataToMerge.forEach(newData => {
      this.merge(mergeKey, newData, changed => {
        if (changed) dataHasChanged = true;
      });
    });

    cb(dataHasChanged);
  }
}