Source: resource_collection.js

import Resource from './resource';

export const NO_RESOURCEFUL_ENDPOINT_ERROR = `
No resourceful endpoint tied to this resource collection.
You should use resourcefulEndpoint.browse(),
or supply a ResourcefulEndpoint instance when creating this collection.
`;

export const NO_NEXT_PAGE_ERROR = 'No next page';
export const NO_PREVIOUS_PAGE_ERROR = 'No previous page';

/**
 * A pagination aware collection of {@link Resource} objects.
 *
 * This class should not be used directly, but should instead be obtained
 * from {@link ResourcefulEndpoint} methods
 *
 * @param {Object} rawData - Raw JSON response from the Sequoia endpoint
 * @param {string} initialCriteria - the initial filter criteria used when requesting from this endpoint
 * @param {ResourcefulEndpoint} resourcefulEndpoint - The {@link ResourcefulEdnpoint} that created this ResourceCollection
 *
 * @property {Object} rawData - Raw JSON response from the Sequoia endpoint
 * @property {string} initialCriteria - the initial filter criteria used when requesting from this endpoint
 * @property {ResourcefulEndpoint} resourcefulEndpoint - The {@link ResourcefulEdnpoint} that created this ResourceCollection
 * @property {Resource[]} collection - Array of {@link Resource} objects
 *
 * @since 0.0.2
 *
 * @private
 */
class ResourceCollection {
  /**
   * @param {Object} rawData - Raw JSON response from the Sequoia endpoint
   * @param {string} initialCriteria - the initial filter criteria used when requesting from this endpoint
   * @param {ResourcefulEndpoint} resourcefulEndpoint - The {@link ResourcefulEndpoint} that created this ResourceCollection
   *
   * @returns {Resource}
   */
  constructor(rawData, initialCriteria, resourcefulEndpoint) {
    /** @property {string} - the initial filter criteria used when requesting from this endpoint */
    this.initialCriteria = initialCriteria;
    /** @property {ResourcefulEndpoint} - The {@link ResourcefulEndpoint} that created this ResourceCollection */
    this.resourcefulEndpoint = resourcefulEndpoint;
    /** @property {Resource[]} - Array of {@link Resource} objects */
    this.collection = [];

    this.setData(rawData);
  }

  /**
   * Updates `rawData` with the json returned from the Sequoia service and sets
   * `collection` to an array of `{@link Resource}s
   *
   * Linked resources are collated into the individual {@link Resource}s created
   * for each item in the collection if they have relationship info. e.g.
   * Assets that are linked to Contents will have a `contentRef` - if this is present,
   * each Content instance will have a linked.assets[] with only the related Assets
   *
   * If there is no relationship specified in linked resources, (e.g. linked Customers
   * against Subscriptions from the Payment service) then each Subscription instance
   * will have *all* of the Customers avaliable as linked.customers[]
   *
   * @param {Object} rawData - json from a Sequoia `browse` request
   *
   * @private
   *
   * @returns {ResourceCollection} - self
   */
  setData(rawData) {
    this.rawData = rawData;
    // Without a descriptor, we don't know truly know what to pick out
    if (
      this.resourcefulEndpoint
      && this.rawData
      && this.rawData[this.resourcefulEndpoint.pluralName]
    ) {
      this.collection = this.rawData[this.resourcefulEndpoint.pluralName].map(
        (content) => {
          const resource = Object.assign({}, content); // Copy
          if (this.rawData.linked) {
            const linkByRefName = `${this.resourcefulEndpoint.singularName}Ref`;
            resource.linked = {};

            Object.keys(this.rawData.linked).forEach((link) => {
              resource.linked[link] = this.rawData.linked[link].filter((item) => {
                // If a `linkByRefName` exists on this link, filter only those
                // that are directly linked (this helps when `browsing()` a list of
                // disparate content where links come back for every item in the list
                // (e.g. metadata/contents -> linked assets).

                // However, some resourcefuls have links without this explicitly set
                // e.g. relationships like credits/relatedMaterials.assets that have
                // a through, or payment/subscriptions -> linked customers
                if (item[linkByRefName]) {
                  return item[linkByRefName] === resource.ref;
                }

                const relationship = (this.resourcefulEndpoint
                    && this.resourcefulEndpoint.resourceful
                    && this.resourcefulEndpoint.resourceful.relationships
                    && this.resourcefulEndpoint.resourceful.relationships[link])
                  || {};

                const { through, filterName } = relationship;
                // if we do not have a through, we are handling payment/customers
                // and we don't want to filter anything out
                if (!through || !filterName) return true;

                const {
                  fieldNamePath
                } = this.resourcefulEndpoint.resourceful.relationships[through];
                let throughFields = resource[fieldNamePath] || [];
                throughFields = Array.isArray(throughFields) ? throughFields : [throughFields];

                // Our first attempt at getting the filter we need relied on what filters were in
                // this.resourcefulEndpoint.filters. This worked fine for metadata/credits, but
                // metadata/people does not have withContentRef. The following code is not pretty,
                // but as the filters follow the same naming convention, deriving the filter we need
                // from the filterName works fine.
                let parsedFilterName = filterName.slice(4);
                parsedFilterName = `${parsedFilterName
                  .charAt(0)
                  .toLowerCase()}${parsedFilterName.slice(1)}`;

                return throughFields.some(
                  field => field === item[parsedFilterName]
                );
              });
            });
          }

          return new Resource(resource, this.resourcefulEndpoint);
        }
      );
    }

    return this;
  }

  /**
   * Current page in the pagination set
   * @type {number}
   */
  get page() {
    return this.rawData.meta.page;
  }

  /**
   * Number of items per page in the pagination set
   * @type {number}
   */
  get perPage() {
    return this.rawData.meta.perPage;
  }

  /**
   * Total number of Resources in the catalogue matching our criteria
   * @type {number}
   */
  get totalCount() {
    if (this.rawData && this.rawData.meta && this.rawData.meta.totalCount) {
      return this.rawData.meta.totalCount;
    }

    return this.collection.length;
  }

  /**
   * Indicates whether there is next page of results available
   *
   * @returns true - if there is a next page
   */
  hasNextPage() {
    return !!this.rawData.meta.next || !!this.rawData.meta.continue;
  }

  /**
   * Fetch the next page of results
   *
   * @see {@link ResourceCollection#fetch}
   *
   * @returns {Promise}
   */
  nextPage() {
    if (this.rawData.meta.next) {
      return this.fetch(this.rawData.meta.next);
    }

    if (this.rawData.meta.continue) {
      return this.fetch(this.rawData.meta.continue);
    }

    return Promise.reject(NO_NEXT_PAGE_ERROR);
  }

  /**
   * Fetch the previous page of results
   *
   * @see {@link ResourceCollection#fetch}
   *
   * @returns {Promise}
   */
  previousPage() {
    if (this.rawData.meta.prev) {
      return this.fetch(this.rawData.meta.prev);
    }

    return Promise.reject(NO_PREVIOUS_PAGE_ERROR);
  }

  /**
   * Fetch the first page of results
   *
   * @see {@link ResourceCollection#fetch}
   *
   * @returns {Promise}
   */
  firstPage() {
    return this.fetch(this.rawData.meta.first);
  }

  /**
   * Fetch the last page of results
   *
   * @see {@link ResourceCollection#fetch}
   *
   * @returns {Promise}
   */
  lastPage() {
    return this.fetch(this.rawData.meta.last);
  }

  /**
   * Fetch a specific page of results
   *
   * @param {number} pageNumber - the number of the page to fetch
   *
   * @todo Boundary checking?
   * @todo This could likely be easier to use with the initialCriteria
   *
   * @see {@link ResourceCollection#fetch}
   *
   * @returns {Promise}
   */
  getPage(pageNumber) {
    return this.fetch(
      this.rawData.meta.first.replace(/&page=\d+/, `&page=${pageNumber}`)
    );
  }

  /**
   * Update the collection with new data from the server. This will
   * also return the new ResourceCollection as a convenience.
   *
   * @param {string?} newCriteria - query string criteria to send to {@link ResourcefulEndpoint#browse}
   *
   * @private
   *
   * @returns {Promise} - The new {@link ResourceCollection} just fetched
   */
  fetch(newCriteria) {
    let criteria = this.initialCriteria || '';

    if (newCriteria) {
      criteria = newCriteria;
    }

    // Remove everything before the query string (meta.first etc all have the full path
    // before the quesy string, which we don't want) and the owner param as the ResourcefulEndpoint
    // will add that
    criteria = criteria.replace(/^.+\?/, '').replace(/&?owner=[^&]+/, '');

    if (this.resourcefulEndpoint) {
      return this.resourcefulEndpoint.browse(criteria).then((newResource) => {
        this.rawData = newResource.rawData;
        this.collection = newResource.collection;

        return newResource;
      });
    }

    return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR);
  }

  /**
   * Get a JSON representation of this collection's keys/values
   *
   * @returns {Object}
   */
  toJSON() {
    return this.collection.map(resource => resource.toJSON());
  }

  /**
   * Get a stringified version of this {@link ResourceCollection} that is suitable
   * for saving to sequoia.
   *
   * Simply wraps the JSON of the resource collection as an array in the <pluralName>[] property
   *
   * @returns {string}
   */
  serialise() {
    return JSON.stringify(
      {
        [this.resourcefulEndpoint.pluralName]: this.toJSON()
      },
      null,
      '  '
    );
  }

  /**
   * Get an array of ResourceCollections populated with a maximum of the `size`
   * paramater {@link Resource}s in each.
   *
   * Sequoia has limits to how many Resources can be saved at once. This method is used
   * internally by {@link ResourceCollection#save} and {@link ResourceCollection#destroy}
   * to send the right amount of data.
   *
   * @param {number} [size={@link ResourcefulEndpoint#batchSize}] - The number of {@link Resource}s to return in each ResourceCollection
   *
   * @see {@link ResourcefulEndpoint#all}
   *
   * @returns {ResourceCollection[]}
   */
  explode(size) {
    const batchSize = size !== undefined ? size : this.resourcefulEndpoint.batchSize;
    const numberOfPages = Math.ceil(this.totalCount / batchSize);

    return Array.from(new Array(numberOfPages), (x, i) => this.resourcefulEndpoint.newResourceCollection(
      this.collection
        .slice(i * batchSize, i * batchSize + batchSize)
        .map(r => r.toJSON())
    ));
  }

  /**
   * Save (create or update) (POST/PUT) all the {@link Resource}s in this
   * ResourceCollection.
   * If the ResourceCollection has more items than the batchSize specified in the
   * descriptor (or supplied batchSize), multiple calls will be made to the backend
   *
   * @param {number} [size={@link ResourcefulEndpoint#batchSize}] - The number of {@link Resource}s to save in each request
   *
   * @see {@link ResourceCollection#explode}
   * @see {@link ResourcefulEndpoint#store}
   *
   * @returns {Promise}
   */
  save(batchSize) {
    if (this.resourcefulEndpoint) {
      return Promise.all(
        this.explode(batchSize).map(c => this.resourcefulEndpoint.store(c).catch((e) => {
          // Add the current collection to the error to allow
          // end users to inspect where the error occurred
          e.collection = c;
          throw e;
        }))
      );
    }

    return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR);
  }

  /**
   * Validate all the {@link Resource}s in this collection.
   *
   * @example
   * resourceCollection.validate().catch((resource) => {
   *   // Show resource.errors[]
   * }).then(resourceCollection => resourceCollection.save())
   * .then(() => {
   *   // do something on successfully saving
   * });
   *
   *
   * @see {@link Resource#validateField}
   *
   * @returns {Promise}
   */
  validate() {
    return Promise.all(
      this.collection.map(resource => resource.validate())
    ).then(() => this);
  }

  /**
   * Destroy (DELETE) all the {@link Resource}s in this ResourceCollection.
   * If the ResourceCollection has more items than the batchSize specified in the
   * descriptor, multiple calls will be made to the backend
   *
   * @example <caption>Destroy all content that went out of availability this year</caption>
   * contents.all(where(field("availabilityEndAt").lessThan("2017"))).then(resources => resources.destroy());
   *
   * @see {@link ResourceCollection#explode}
   * @see {@link ResourcefulEndpoint#destroy}
   *
   * @returns {Promise}
   */
  destroy() {
    if (this.resourcefulEndpoint) {
      return Promise.all(
        this.explode().map(c => this.resourcefulEndpoint.destroy(
          c.collection.map(r => r.ref).join(',')
        ))
      );
    }

    return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR);
  }

  // Pragma Local collection methods
  /**
   * Add a {@link Resource} to the [local collection]{@link ResourceCollection#collection}
   * Note: this method does not implement any uniqueness constraints on the `ref`s of
   * objects/Resources being added. If this is required, use {@link ResourceCollection#findOrCreate}
   *
   * @param {Object|Resource} data - Can be either an existing Resource or a JSON object
   * to create a new Resource from
   *
   * @see {@link ResourcefulEndpoint#newResource}
   *
   * @returns {!Resource}
   */
  add(data) {
    let resource = data;

    if (!(data instanceof Resource)) {
      resource = this.resourcefulEndpoint.newResource(data);
    }

    this.collection.push(resource);

    return resource;
  }

  /**
   * Remove a {@link Resource} from the [local collection]{@link ResourceCollection#collection}
   * Will return the found {@link Resource} or null if it does not exist
   *
   * @param {string} ref - the ref of the resource to remove
   *
   * @returns {?Resource}
   */
  remove(ref) {
    const index = this.collection.findIndex(r => r.ref === ref);

    if (index === -1) {
      return null;
    }

    const [resource] = this.collection.splice(index, 1);
    return resource;
  }

  /**
   * Returns a {@link Resource} if it exists in the [local collection]{@link ResourceCollection#collection}
   * with the supplied `ref`
   *
   * @param {string} ref - The ref of the resource to find
   *
   * @returns {?Resource}
   */
  find(ref) {
    return this.collection.find(r => r.ref === ref);
  }

  /**
   * Find a resource in the [local collection]{@link ResourceCollection#collection} or create (and add
   * to the local collection) a Resource from the supplied object
   *
   * @param {Resource|Object} - the resource to find or create
   *
   * @returns {!Resource}
   */
  findOrCreate(resource) {
    const existingResource = this.findWhere(
      resource instanceof Resource ? resource.toJSON() : resource
    );

    if (existingResource) {
      return existingResource;
    }

    return this.add(resource);
  }

  /**
   * Filter on `ref` when a string is supplied.
   * Filter by a custom function by supplying a `function`
   * Or filter by key/value pairs in an Object
   * @typedef {(string|function|Object)} Criteria
   */
  /**
   * Find a Resource in the [local collection]{@link ResourceCollection#collection} or `null` if not found
   * The below examples assume the variable contents is populated with the
   * result of `client.service('metadata').then(s => s.resourcefulEndpoint('contents').all())`
   *
   * @param {Criteria} - The criteria to use for filtering the local collection
   *
   * @example <caption>Find all with a custom filter function</caption>
   * // Find all of the Resources that have a title including 'die hard' that haven't had any tags applied yet
   * contents.where(r => r.title.toLowerCase().includes('die hard') && !Array.isArray(r.tags))
   *
   * @example <caption>Find all that match a given object</caption>
   * // Find all of the Resources that are active and have a type of 'show'
   * contents.where({ type: 'show', active: true })
   *
   * @see {@link ResourceCollection#where}
   *
   * @returns {Resource[]}
   */
  where(criteria) {
    let callback;

    if (typeof criteria === 'function') {
      callback = criteria;
    } else if (typeof criteria === 'object' && criteria !== null) {
      // Short circuit when we have a ref:
      if (criteria.hasOwnProperty('ref') && criteria.ref !== undefined) {
        callback = r => r.ref === criteria.ref;
      } else {
        callback = r => Object.keys(criteria).reduce((acc, key) => {
          if (acc === true) {
            return r[key] === criteria[key];
          }

          return false;
        }, true);
      }
    } else {
      return [];
    }

    return this.collection.filter(callback);
  }

  /**
   * Find a Resource in the [local collection]{@link ResourceCollection#collection} or `null` if not found
   *
   * @param {Criteria} - See {@link ResourceCollection#where}
   *
   * @see {@link ResourceCollection#where}
   *
   * @returns {?Resource}
   */
  findWhere(criteria) {
    const resources = this.where(criteria);

    if (resources.length) {
      return resources[0];
    }

    return null;
  }
}

export default ResourceCollection;